Sign in

Action as data

Suggest an edit

Some actions need a signer who is offline, or a condition that isn't true yet. Here the Action as Data pattern comes in.

On Solana, a transaction signature is valid only for a short period of about a minute. So the common EVM flow does not work: sign an order once, let a server submit it later. No signed orders waiting for a taker, no approvals collected over a week. A durable nonce keeps one prebuilt transaction alive longer, but the signature still cannot be reused, and the accounts cannot change.

One workaround is to keep the user's request on a server and let it act with its own key. But then the server signs transactions that move the user's money. It can crash, censor, or be taken over.

Action as Data is the pattern of storing the future action on-chain, without executing it. A Program Derived Account (PDA) stores the target program address, the account list and the instruction payload. Later, when the conditions are met, a keeper (such as an untrusted executor) submits the transaction, and the PDA approves the transaction with invoke_signed. The action that the user put on-chain before its execution was always public: anyone could read it and execute it once the condition is met.

Action as dataA user puts an action into an account, drawn as a document inside a box with a clock next to it, meaning it waits. Later a keeper sends execute, and the PDA signs for the user. Action as data user stores account waits later anyone execute target PDA signs The action waits on-chain as data. When its time comes, anyone sends it and the PDA signs for the user.

Examples

  • Squads creates an account for every proposed transaction, storing the full list of instructions inside it. Members approve the proposal one by one. Once enough members vote yes, anyone can trigger the execution (even if they aren't a member), and the program named 'vault' will execute the stored instructions.
  • Realms does the same, by keeping governance instructions inside the proposal account itself. Once a vote passes, any user could trigger the stored instructions to execute.
  • A Jupiter limit order is an account with a price, DCA is an account with a schedule, and when the price or the time comes, anyone can fill it.

The only trade-off in this pattern is that the rent for every pending action has to be paid, and the funds are locked until it runs. So every action needs a way to cancel or expire.

In code

The action is stored as plain data, and a later transaction runs it with the PDA signing for the user.

rust
#[account]
pub struct PendingAction {
    pub target_program: Pubkey,
    pub accounts: Vec<StoredAccount>, // pubkey, is_signer, is_writable
    pub data: Vec<u8>,
    pub run_after: i64,
}

// anyone can run it once the condition holds; the PDA signs in place of the user
pub fn execute(ctx: Context<Execute>) -> Result<()> {
    let action = &ctx.accounts.pending;
    require!(Clock::get()?.unix_timestamp >= action.run_after, Error::TooEarly);
    invoke_signed(&action.to_instruction(), ctx.remaining_accounts, &[&[b"vault", &[ctx.bumps.vault]]])?;
    Ok(())
}