diff --git a/docs/solana_program.md b/docs/solana_program.md index 3cc866bc..f483c7c1 100644 --- a/docs/solana_program.md +++ b/docs/solana_program.md @@ -16,6 +16,14 @@ Initializes a new Bridge at `bridge`. | 3 | guardian_set | GuardianSet | | ✅ | ✅ | ✅ | | 4 | payer | Account | ✅ | | | | +#### PokeProposal + +Pokes a `TransferOutProposal` so it is reprocessed by the guardians. + +| Index | Name | Type | signer | writeable | empty | derived | +| ----- | ------ | ------------ | ------ | --------- | ----- | ------- | +| 0 | proposal | TransferOutProposal | | ✅ | ️ | ✅ | + #### TransferOut Burns a wrapped asset `token` from `sender` on the Solana chain. @@ -29,11 +37,12 @@ Parameters: | 0 | bridge_p | BridgeProgram | | | ️ | | | 1 | sys | SystemProgram | | | ️ | | | 2 | token_program | SplToken | | | ️ | | -| 3 | token_account | TokenAccount | | ✅ | | | -| 4 | bridge | BridgeConfig | | | | | -| 5 | proposal | TransferOutProposal | | ✅ | ✅ | ✅ | -| 6 | token | WrappedAsset | | ✅ | | ✅ | -| 7 | payer | Account | ✅ | | | | +| 3 | clock | Sysvar | | | ️ | ✅ | +| 4 | token_account | TokenAccount | | ✅ | | | +| 5 | bridge | BridgeConfig | | | | | +| 6 | proposal | TransferOutProposal | | ✅ | ✅ | ✅ | +| 7 | token | WrappedAsset | | ✅ | | ✅ | +| 8 | payer | Account | ✅ | | | | #### TransferOutNative @@ -47,12 +56,13 @@ The transfer proposal will be tracked at a new account `proposal` where a VAA wi | 0 | bridge_p | BridgeProgram | | | ️ | | | 1 | sys | SystemProgram | | | ️ | | | 2 | token_program | SplToken | | | ️ | | -| 3 | token_account | TokenAccount | | ✅ | | | -| 4 | bridge | BridgeConfig | | | | | -| 5 | proposal | TransferOutProposal | | ✅ | ✅ | ✅ | -| 6 | token | Mint | | ✅ | | | -| 7 | payer | Account | ✅ | | | | -| 8 | custody_account | TokenAccount | | ✅ | opt | ✅ | +| 3 | clock | Sysvar | | | ️ | ✅ | +| 4 | token_account | TokenAccount | | ✅ | | | +| 5 | bridge | BridgeConfig | | | | | +| 6 | proposal | TransferOutProposal | | ✅ | ✅ | ✅ | +| 7 | token | Mint | | ✅ | | | +| 8 | payer | Account | ✅ | | | | +| 9 | custody_account | TokenAccount | | ✅ | opt | ✅ | #### EvictTransferOut diff --git a/solana/agent/src/main.rs b/solana/agent/src/main.rs index a25ad9ac..0bca2560 100644 --- a/solana/agent/src/main.rs +++ b/solana/agent/src/main.rs @@ -137,14 +137,6 @@ impl Agent for AgentImpl { println!("lockup changed in slot: {}", v.context.slot); - let time = match rpc.get_block_time(v.context.slot) { - Ok(v) => v as u64, - Err(e) => { - println!("failed to fetch block time for event: {}", e); - continue; - } - }; - let b = match Bridge::unpack_immutable::( v.value.account.data.as_slice(), ) { @@ -163,7 +155,7 @@ impl Agent for AgentImpl { LockupEvent { slot: v.context.slot, lockup_address: v.value.pubkey.to_string(), - time, + time: b.lockup_time as u64, event: Some(Event::New(LockupEventNew { nonce: b.nonce, source_chain: CHAIN_ID_SOLANA as u32, @@ -181,7 +173,7 @@ impl Agent for AgentImpl { LockupEvent { slot: v.context.slot, lockup_address: v.value.pubkey.to_string(), - time, + time: b.lockup_time as u64, event: Some(Event::VaaPosted(LockupEventVaaPosted { nonce: b.nonce, source_chain: CHAIN_ID_SOLANA as u32, diff --git a/solana/bridge/src/instruction.rs b/solana/bridge/src/instruction.rs index 51344e92..859709eb 100644 --- a/solana/bridge/src/instruction.rs +++ b/solana/bridge/src/instruction.rs @@ -14,7 +14,7 @@ use solana_sdk::{ use crate::error::Error; use crate::error::Error::VAATooLong; -use crate::instruction::BridgeInstruction::{Initialize, PostVAA, TransferOut}; +use crate::instruction::BridgeInstruction::{Initialize, PokeProposal, PostVAA, TransferOut}; use crate::state::{AssetMeta, Bridge, BridgeConfig}; use crate::vaa::{VAABody, VAA}; @@ -123,6 +123,9 @@ pub enum BridgeInstruction { /// Deletes a `ExecutedVAA` after the `VAA_EXPIRATION_TIME` is over to free up space on chain. /// This returns the rent to the sender. EvictClaimedVAA(), + + /// Pokes a proposal with no valid VAAs attached so guardians reprocess it. + PokeProposal(), } impl BridgeInstruction { @@ -153,6 +156,7 @@ impl BridgeInstruction { let payload: VAAData = input[1..].to_vec(); PostVAA(payload) } + 5 => PokeProposal(), _ => return Err(ProgramError::InvalidInstructionData), }) } @@ -201,6 +205,9 @@ impl BridgeInstruction { Self::EvictClaimedVAA() => { output[0] = 4; } + Self::PokeProposal() => { + output[0] = 5; + } } Ok(output) } @@ -273,6 +280,7 @@ pub fn transfer_out( AccountMeta::new_readonly(*program_id, false), AccountMeta::new_readonly(solana_sdk::system_program::id(), false), AccountMeta::new_readonly(spl_token::id(), false), + AccountMeta::new_readonly(solana_sdk::sysvar::clock::id(), false), AccountMeta::new(*token_account, false), AccountMeta::new(bridge_key, false), AccountMeta::new(transfer_key, false), @@ -374,6 +382,23 @@ pub fn post_vaa( }) } +/// Creates an 'PokeProposal' instruction. +#[cfg(not(target_arch = "bpf"))] +pub fn poke_proposal( + program_id: &Pubkey, + transfer_proposal: &Pubkey, +) -> Result { + let data = BridgeInstruction::PokeProposal().serialize()?; + + let mut accounts = vec![AccountMeta::new(*transfer_proposal, false)]; + + Ok(Instruction { + program_id: *program_id, + accounts, + data, + }) +} + /// Unpacks a reference from a bytes buffer. pub fn unpack(input: &[u8]) -> Result<&T, ProgramError> { if input.len() < size_of::() + size_of::() { diff --git a/solana/bridge/src/processor.rs b/solana/bridge/src/processor.rs index 524b581d..3e8e2e20 100644 --- a/solana/bridge/src/processor.rs +++ b/solana/bridge/src/processor.rs @@ -59,6 +59,11 @@ impl Bridge { Self::process_vaa(program_id, accounts, vaa_body, &vaa) } + PokeProposal() => { + info!("Instruction: PokeProposal"); + + Self::process_poke(program_id, accounts) + } _ => panic!(""), } } @@ -133,6 +138,23 @@ impl Bridge { Ok(()) } + /// Transfers a wrapped asset out + pub fn process_poke(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let proposal_info = next_account_info(account_info_iter)?; + + let mut transfer_data = proposal_info.data.borrow_mut(); + let mut proposal: &mut TransferOutProposal = Self::unpack(&mut transfer_data)?; + if proposal.vaa_time != 0 { + return Err(Error::VAAAlreadySubmitted.into()); + } + + // Increase poke counter + proposal.poke_counter += 1; + + Ok(()) + } + /// Transfers a wrapped asset out pub fn process_transfer_out( program_id: &Pubkey, @@ -144,6 +166,7 @@ impl Bridge { next_account_info(account_info_iter)?; // Bridge program next_account_info(account_info_iter)?; // System program next_account_info(account_info_iter)?; // Token program + let clock_info = next_account_info(account_info_iter)?; let sender_account_info = next_account_info(account_info_iter)?; let bridge_info = next_account_info(account_info_iter)?; let transfer_info = next_account_info(account_info_iter)?; @@ -153,6 +176,7 @@ impl Bridge { let sender = Bridge::token_account_deserialize(sender_account_info)?; let bridge = Bridge::bridge_deserialize(bridge_info)?; let mint = Bridge::mint_deserialize(mint_info)?; + let clock = Clock::from_account_info(clock_info)?; // Does the token belong to the mint if sender.mint != *mint_info.key { @@ -209,6 +233,7 @@ impl Bridge { transfer.foreign_address = t.target; transfer.amount = t.amount; transfer.to_chain_id = t.chain_id; + transfer.lockup_time = clock.unix_timestamp as u32; // Make sure decimals are correct transfer.asset = AssetMeta { @@ -231,6 +256,7 @@ impl Bridge { next_account_info(account_info_iter)?; // Bridge program next_account_info(account_info_iter)?; // System program next_account_info(account_info_iter)?; // Token program + let clock_info = next_account_info(account_info_iter)?; let sender_account_info = next_account_info(account_info_iter)?; let bridge_info = next_account_info(account_info_iter)?; let transfer_info = next_account_info(account_info_iter)?; @@ -241,6 +267,7 @@ impl Bridge { let sender = Bridge::token_account_deserialize(sender_account_info)?; let mint = Bridge::mint_deserialize(mint_info)?; let bridge = Bridge::bridge_deserialize(bridge_info)?; + let clock = Clock::from_account_info(clock_info)?; // Does the token belong to the mint if sender.mint != *mint_info.key { @@ -317,6 +344,7 @@ impl Bridge { transfer.source_address = sender_account_info.key.to_bytes(); transfer.foreign_address = t.target; transfer.nonce = t.nonce; + transfer.lockup_time = clock.unix_timestamp as u32; // Don't use the user-given data as we don't check mint = AssetMeta.address transfer.asset = AssetMeta { diff --git a/solana/bridge/src/state.rs b/solana/bridge/src/state.rs index eca40bc4..fc956871 100644 --- a/solana/bridge/src/state.rs +++ b/solana/bridge/src/state.rs @@ -69,6 +69,10 @@ pub struct TransferOutProposal { pub vaa: [u8; MAX_VAA_SIZE + 1], /// time the vaa was submitted pub vaa_time: u32, + /// time the lockup was created + pub lockup_time: u32, + /// times the proposal has been poked + pub poke_counter: u8, /// Is `true` if this structure has been initialized. pub is_initialized: bool, diff --git a/solana/cli/src/main.rs b/solana/cli/src/main.rs index 64877e91..f50f093a 100644 --- a/solana/cli/src/main.rs +++ b/solana/cli/src/main.rs @@ -85,6 +85,18 @@ fn command_deploy_bridge( Ok(Some(transaction)) } +fn command_poke_proposal(config: &Config, bridge: &Pubkey, proposal: &Pubkey) -> CommmandResult { + println!("Poking lockup"); + + let ix = poke_proposal(bridge, proposal)?; + let mut transaction = Transaction::new_with_payer(&[ix], Some(&config.fee_payer.pubkey())); + + let (recent_blockhash, fee_calculator) = config.rpc_client.get_recent_blockhash()?; + check_fee_payer_balance(config, fee_calculator.calculate_fee(&transaction.message()))?; + transaction.sign(&[&config.fee_payer, &config.owner], recent_blockhash); + Ok(Some(transaction)) +} + fn command_lock_tokens( config: &Config, bridge: &Pubkey, @@ -954,6 +966,34 @@ fn main() { .help("The vaa to be posted"), ) ) + .subcommand( + SubCommand::with_name("poke") + .about("Poke a proposal so it's retried") + .arg( + Arg::with_name("bridge") + .long("bridge") + .value_name("BRIDGE_KEY") + .validator(is_pubkey_or_keypair) + .takes_value(true) + .index(1) + .required(true) + .help( + "Specify the bridge program public key" + ), + ) + .arg( + Arg::with_name("proposal") + .long("proposal") + .value_name("PROPOSAL_KEY") + .validator(is_pubkey_or_keypair) + .takes_value(true) + .index(2) + .required(true) + .help( + "Specify the transfer proposal to poke" + ), + ) + ) .subcommand( SubCommand::with_name("wrapped-address") .about("Derive wrapped asset address") @@ -1114,6 +1154,11 @@ fn main() { let vaa = hex::decode(vaa_string).unwrap(); command_submit_vaa(&config, &bridge, vaa.as_slice()) } + ("poke", Some(arg_matches)) => { + let bridge = pubkey_of(arg_matches, "bridge").unwrap(); + let proposal = pubkey_of(arg_matches, "proposal").unwrap(); + command_poke_proposal(&config, &bridge, &proposal) + } ("wrapped-address", Some(arg_matches)) => { let bridge = pubkey_of(arg_matches, "bridge").unwrap(); let chain = value_t_or_exit!(arg_matches, "chain", u8); diff --git a/web/src/components/TransferProposals.tsx b/web/src/components/TransferProposals.tsx index 943de0c8..b2fc7fff 100644 --- a/web/src/components/TransferProposals.tsx +++ b/web/src/components/TransferProposals.tsx @@ -9,7 +9,9 @@ import {WormholeFactory} from "../contracts/WormholeFactory"; import {BRIDGE_ADDRESS} from "../config"; import {keccak256} from "ethers/utils"; import BN from 'bn.js'; -import {PublicKey} from "@solana/web3.js"; +import {PublicKey, Transaction} from "@solana/web3.js"; +import KeyContext from "../providers/KeyContext"; +import ClientContext from "../providers/ClientContext"; // @ts-ignore window.ethereum.enable(); @@ -32,6 +34,8 @@ function TransferProposals() { let t = useContext(SolanaTokenContext); let tokens = useContext(SolanaTokenContext); let b = useContext(BridgeContext); + let k = useContext(KeyContext); + let c = useContext(ClientContext); let [lockups, setLockups] = useState([]) @@ -84,13 +88,31 @@ function TransferProposals() { message.loading({content: "Waiting for transaction to be mined...", key: "eth_tx", duration: 1000}) await tx.wait(1) message.success({content: "Execution of VAA succeeded", key: "eth_tx"}) + } + let pokeProposal = async (proposalAddress: PublicKey) => { + message.loading({content: "Poking lockup ...", key: "poke"}, 1000) + + let ix = await b.createPokeProposalInstruction(proposalAddress); + let recentHash = await c.getRecentBlockhash(); + let tx = new Transaction(); + tx.recentBlockhash = recentHash.blockhash + tx.add(ix) + tx.sign(k) + try { + await c.sendTransaction(tx, [k]) + message.success({content: "Poke succeeded", key: "poke"}) + } catch (e) { + message.error({content: "Poke failed", key: "poke"}) + } } let statusToPrompt = (v: LockupWithStatus) => { switch (v.status) { case LockupStatus.AWAITING_VAA: - return ("Awaiting VAA"); + return (<>Awaiting VAA ( { + pokeProposal(v.lockupAddress) + }}>poke)); case LockupStatus.UNCLAIMED_VAA: return (