Skip to content

Repository files navigation

Presign

A pre-signature intent firewall for AI agents that hold Solana wallets.

The agent declares, as structured data, what it means to do. Presign takes the unsigned transaction, simulates it read-only, computes the effects that simulation actually reports, and blocks the transaction when those effects do not match the declaration. It runs locally as a library or a CLI. There is no hosted service and no third party in the signing path.

Presign never signs, sends or confirms anything. The only transaction-touching RPC call it makes is simulateTransaction with sigVerify:false, and test/safety.test.js fails the build if that ever stops being true.

Status: first milestone. The simulation and effect-diff engine described below is built and tested. The on-chain enforcement path is not. See Detectors for what exists today and Limitations for what it does not catch.

The threat

Agent frameworks now hand an AI agent its own keypair. The agent decides what to sign, and a Solana transaction is irreversible once it lands.

The guards that exist today are built for humans. A wallet renders a preview and a person reads it before approving. An autonomous agent has no person in that loop, and the hosted scanning services that produce those previews are a third party you have to trust with your unsigned transactions.

The failure is rarely a broken signature scheme. It is a transaction whose bytes do not say what the agent thinks they say. An instruction arrives through a webpage, an email, a tool result or a poisoned memory, and the agent signs something adjacent to what it intended:

  • a second transfer appended to the payment, going somewhere the agent never named
  • an amount an order of magnitude above the one that was approved
  • a delegate quietly granted over a token account, which drains later in a different transaction
  • a token account authority handed to someone else
  • a program reached through CPI that never appeared in the instruction list

Every one of those is visible before the key is used, either in the transaction's own instructions or in the effects a read-only simulation reports. The intent is what a human or a supervising process approved. The transaction is what will execute. Presign compares them.

Install

git clone <this repo>
cd presign
npm install
npm test

Node 20 or newer. Runtime dependencies are @solana/web3.js v1 and bs58. The test suite uses node:test and needs no test framework. @solana/spl-token is a dev dependency, used only to build the example transactions and to verify instruction tag values. TypeScript is a dev dependency too, used as a checker rather than a compiler: npm run typecheck type-checks this JavaScript through its JSDoc annotations. There is no build step and nothing is transpiled.

Run

CLI

node bin/presign.js --tx <base64|@file> --intent <file.json> [options]

--tx takes base64 directly, or @path to read it from a file. Options:

option effect
--rpc <url> RPC endpoint. Defaults to $PRESIGN_RPC, then to the public mainnet-beta endpoint.
--sim <file> Replay a recorded simulation instead of calling an RPC. Fully offline.
--json Print the whole result as JSON, including per-finding evidence.
--strict Make WARN exit non-zero as well as BLOCK.
--quiet Print only the verdict line.

Exit codes are 0 for ALLOW, 2 for BLOCK, 3 for WARN under --strict, and 1 for a usage or input error. The exit code is the integration surface. A wrapper script can gate signing on it without parsing any output.

Library

import { Connection } from '@solana/web3.js';
import { evaluate } from './src/presign.js';

const connection = new Connection('https://api.mainnet-beta.solana.com', 'confirmed');
const result = await evaluate({ connection, transaction, intent });

if (result.verdict === 'BLOCK') {
  // do not sign
  console.error(result.findings.map((f) => `${f.id}: ${f.detail}`).join('\n'));
}

evaluate takes an unsigned VersionedTransaction or a legacy Transaction and returns { verdict, findings, summary, effects, intent }. verdict is ALLOW, WARN or BLOCK. Any single BLOCK-severity finding produces a BLOCK verdict.

Two pure entry points exist for callers that already hold a simulation result, and they touch no network at all: evaluateEffects({ effects, intent }) and evaluateSimulation({ message, simValue, intent }). An agent runtime that already simulates should use those. Only evaluate opens a connection.

Worked examples

Both examples below are real output, and both reproduce with no network and no RPC endpoint. Every example transaction in examples/ ships with the recorded result of simulating that exact transaction against mainnet-beta, so --sim replays the same bytes a live run would have seen. npm run example rebuilds the transactions and re-records them against current mainnet state.

The intent for the first two cases authorises exactly one 0.1 SOL payment to one address:

{
  "action": "send 0.1 SOL to the invoice address",
  "description": "agent paying a single declared invoice",
  "maxLamportsOut": 100000000,
  "allowedRecipients": ["BXpvjvv4bJdxuHfK7zE8MvtsmENXvAmErfKShvMYqjLF"],
  "allowedPrograms": ["11111111111111111111111111111111"]
}

ALLOW: the transaction does what it said

examples/tx-honest.b64 is a single System Program transfer of exactly 0.1 SOL to the declared address.

$ node bin/presign.js --tx @examples/tx-honest.b64 --sim examples/sim-honest.json \
    --intent examples/intent-transfer.json
verdict: ALLOW  (0 findings)
intent:  send 0.1 SOL to the invoice address
source:  recorded simulation examples/sim-honest.json (offline, no RPC call)
payer:   7UK3Hv64iGdxwkpmrCkQPuKbPjqjeYZRmsUw4rQV66DM
lamports: debited 100005000, fee 5000, rent excluded 0, net out 100000000 of 100000000 allowed
compute:  150 units
programs: 11111111111111111111111111111111

lamport deltas:
  -100005000        7UK3Hv64iGdxwkpmrCkQPuKbPjqjeYZRmsUw4rQV66DM
  +100000000        BXpvjvv4bJdxuHfK7zE8MvtsmENXvAmErfKShvMYqjLF

no findings: the simulated effects stay inside the declared intent
$ echo $?
0

Note net out 100000000 of 100000000 allowed. The payment sits exactly on its cap and the 5000 lamport fee does not push it over, because the fee is accounted separately before the cap is applied. A firewall that blocks ordinary transactions gets switched off within a day, so the honest case is tested as carefully as the attacks.

BLOCK: a second transfer smuggled in

examples/tx-smuggled.b64 is the same 0.1 SOL payment with a second System Program transfer of 2 SOL appended, going to an address the intent never mentions.

$ node bin/presign.js --tx @examples/tx-smuggled.b64 --sim examples/sim-smuggled.json \
    --intent examples/intent-transfer.json
verdict: BLOCK  (2 findings)
intent:  send 0.1 SOL to the invoice address
source:  recorded simulation examples/sim-smuggled.json (offline, no RPC call)
payer:   7UK3Hv64iGdxwkpmrCkQPuKbPjqjeYZRmsUw4rQV66DM
lamports: debited 2100005000, fee 5000, rent excluded 0, net out 2100000000 of 100000000 allowed
compute:  300 units
programs: 11111111111111111111111111111111

lamport deltas:
  -2100005000       7UK3Hv64iGdxwkpmrCkQPuKbPjqjeYZRmsUw4rQV66DM
  +100000000        BXpvjvv4bJdxuHfK7zE8MvtsmENXvAmErfKShvMYqjLF
  +2000000000       75CpZFcFejKN2iw5GnCxa4g66xuGCmsUjnteM8HjCB99

findings:
  [BLOCK] Lamports go to an account the intent did not authorise
          75CpZFcFejKN2iw5GnCxa4g66xuGCmsUjnteM8HjCB99 gains 2000000000 lamports but is not in allowedRecipients.
          id=undeclared_lamport_recipient
  [BLOCK] Lamport outflow exceeds the declared cap
          intent allows 100000000 lamports out; this transaction moves 2100000000 (total debited 2100005000, fee 5000, rent deposits excluded 0).
          id=outflow_cap_exceeded
$ echo $?
2

Drop --sim and the same command simulates live instead. The source: line then names the endpoint rather than the file.

examples/tx-over-cap.b64 is a third variant: the declared recipient, the declared program, but 2.5 SOL instead of 0.1. It blocks with a single outflow_cap_exceeded finding and exit code 2.

BLOCK: an undeclared program reached through CPI

examples/tx-ata.b64 opens an associated token account. It has one top-level instruction, addressed to the Associated Token Account program, and nothing in the instruction list mentions any other program. That program then invokes the System program and the SPL Token program internally. An intent that declares only the ATA program is under-declared, and Presign says so:

$ node bin/presign.js --tx @examples/tx-ata.b64 --sim examples/sim-ata.json \
    --intent examples/intent-ata-underdeclared.json
verdict: BLOCK  (2 findings)
intent:  open a USDC account for the counterparty
source:  recorded simulation examples/sim-ata.json (offline, no RPC call)
payer:   7UK3Hv64iGdxwkpmrCkQPuKbPjqjeYZRmsUw4rQV66DM
lamports: debited 2044280, fee 5000, rent excluded 2039280, net out 0 of 0 allowed
compute:  16480 units
programs: ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL, TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA, 11111111111111111111111111111111
via CPI:  TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA, 11111111111111111111111111111111

lamport deltas:
  -2044280          7UK3Hv64iGdxwkpmrCkQPuKbPjqjeYZRmsUw4rQV66DM
  +2039280          2Mo1H4xvb5GDNY8PTCsTvRQis3AauBY4r4jv9WHAjCkZ

findings:
  [BLOCK] An undeclared program is invoked through CPI
          TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA is not in allowedPrograms and appears only as an inner (CPI) invocation.
          id=undeclared_program_cpi
  [BLOCK] An undeclared program is invoked through CPI
          11111111111111111111111111111111 is not in allowedPrograms and appears only as an inner (CPI) invocation.
          id=undeclared_program_cpi

The same transaction against examples/intent-ata-complete.json, which declares all three programs, is verdict: ALLOW (0 findings) and exit code 0.

Look at the rent excluded 2039280 line. The 0.00203928 SOL that opening an account deposits for rent is reported separately and is not charged against maxLamportsOut, so an honest account-opening transaction with a zero payment budget does not trip the cap.

Intent schema

{
  "action": "send 0.1 SOL to the invoice address",
  "description": "optional free text",

  "maxLamportsOut": 100000000,
  "allowedRecipients": ["<base58 pubkey>", "..."],
  "allowedPrograms": ["<base58 program id>", "..."],

  "allowedTokenMints": ["<base58 mint>"],
  "maxTokenOut": { "<base58 mint>": "1000000" },

  "maxRentLamports": 10000000,
  "rentPerAccountCeiling": 5000000,

  "allowCloseAccounts": false,
  "allowSetAuthority": false,
  "allowDelegate": false,
  "allowBurn": false,
  "implicitlyAllowComputeBudget": true
}
field meaning
action Required, non-empty. Free text label for what the agent claims to be doing.
maxLamportsOut Required. Cap on net lamports leaving, excluding the fee and excluding rent deposits within the rent allowance.
allowedRecipients Required. Accounts permitted to gain lamports or tokens. For token movement, either the destination token account or its owner satisfies the check.
allowedPrograms Required. Programs permitted to be invoked, at the top level or through CPI.
allowedTokenMints Optional. When absent, any token movement at all is a BLOCK.
maxTokenOut Optional. Per-mint cap in base units. Accepts a digit string so amounts above 2^53 stay exact. A mint that leaves with no entry here is a BLOCK.
maxRentLamports Optional, default 10000000. Total rent deposit excluded from the outflow cap.
rentPerAccountCeiling Optional, default 5000000. A credit to a previously non-existent account counts as rent only up to this size, so a large payment to a fresh address is not misread as rent.
allowCloseAccounts, allowSetAuthority, allowDelegate, allowBurn Optional, default false. Opt in to the corresponding operation.
implicitlyAllowComputeBudget Optional, default true. The ComputeBudget program cannot move value, so it is allowed without being declared and reported as an INFO finding. Set false to require it.

Validation is fail-closed and reports every problem at once rather than stopping at the first. Unknown top-level fields are an error, because a typo such as allowedRecipient would otherwise leave the real field absent and silently widen the policy.

Detectors

20 distinct findings, emitted by 14 detector functions in src/detectors.js. Those two numbers are different and are not interchangeable. test/findings.test.js asserts both, and asserts that this table, the registry in src/findings.js and the finding(...) call sites in the detector source all agree on which findings exist and what severity each one carries. Adding a detector without documenting it here fails the build.

Everything in this table is implemented, and each one has tests covering both the case where it fires and the honest case where it must stay quiet.

id severity status what it catches
simulation_failed BLOCK implemented Simulation returned an error, so no effects can be computed. Presign refuses rather than assuming a failing transaction is harmless.
inner_instructions_unavailable WARN implemented The RPC did not return inner instructions, so CPI cannot be enumerated.
undeclared_lamport_recipient BLOCK implemented An account gains lamports without being in allowedRecipients. Credits back to the fee payer are inflows and are ignored.
outflow_cap_exceeded BLOCK implemented Net lamports out exceed maxLamportsOut, after subtracting the fee and the allowed rent.
undeclared_token_recipient BLOCK implemented A token account gains tokens and neither it nor its owner is in allowedRecipients.
undeclared_token_movement BLOCK implemented Token balances move but the intent declared no allowedTokenMints.
undeclared_mint BLOCK implemented A mint outside allowedTokenMints moves.
token_outflow_uncapped BLOCK implemented Tokens leave a mint with no maxTokenOut entry.
token_outflow_cap_exceeded BLOCK implemented Token outflow exceeds the per-mint cap.
undeclared_program BLOCK implemented A top-level instruction addresses a program not in allowedPrograms.
undeclared_program_cpi BLOCK implemented A program appears only as an inner invocation and is not declared.
token_set_authority BLOCK implemented SPL Token SetAuthority, including through CPI. The decoded authority type is reported.
token_delegate_approve BLOCK implemented SPL Token Approve or ApproveChecked.
token_close_account BLOCK implemented SPL Token CloseAccount.
system_assign BLOCK implemented SystemProgram.assign or assignWithSeed, which moves an account under a different owning program.
durable_nonce BLOCK implemented The transaction advances a nonce account.
nonce_account_management WARN implemented A nonce account is initialised, its authority changed, or lamports withdrawn from it.
token_burn WARN implemented SPL Token Burn or BurnChecked. Burned value has no recipient, so no recipient check sees it.
token_2022_involved WARN implemented Token-2022 appears among the invoked programs.
compute_budget_implicitly_allowed INFO implemented ComputeBudget was permitted without being declared.

Two of these are BLOCK rather than WARN for reasons worth stating.

token_delegate_approve blocks because a delegate is the drain vector that per-transaction accounting cannot see. The approving transaction moves nothing, so every balance check passes. The drain happens in a different transaction, later, and by then the authority is already granted.

durable_nonce blocks because a nonce transaction is not tied to a recent blockhash. It can land arbitrarily far in the future, against state that has nothing to do with the state that was simulated, so the freshness that makes the simulation informative is gone.

Planned, not built

None of the following exists in this repository. It is listed so the roadmap is not mistaken for the feature set.

capability status note
Lighthouse assertion compilation planned Compile the same intent into on-chain assertion instructions and append them to the transaction, so a violation fails atomically at execution. This is the answer to the simulation gap described under Limitations. No Lighthouse instruction is built today.
Session budgets across transactions planned Tie a series of transactions to one cumulative spending envelope, which is what catches salami slicing. Presign has no memory between transactions today.
Token-2022 extension modelling planned Decode transfer hooks, transfer fees, permanent delegate and confidential transfer state. Today Token-2022 is flagged with a WARN and nothing more.
MCP server packaging planned Expose the check to agent runtimes over MCP. Only the library and the CLI exist today.
Adversarial corpus and published evaluation planned Attack the tool deliberately, including programs that simulate benign and execute malicious, and publish what gets through.

How the effects are computed

simulateTransaction is called with sigVerify:false, replaceRecentBlockhash:true and innerInstructions:true. Because signatures are not verified, an unsigned transaction can be simulated, and a fee payer whose key the caller does not hold can be used. That is how the live tests run against mainnet with no funded account anywhere.

The account key list that preBalances, postBalances, preTokenBalances and postTokenBalances are indexed against is the static account keys of the message, then the address-lookup-table writable addresses, then the lookup-table readonly addresses, in that order. Getting this concatenation wrong on a v0 transaction attributes deltas to the wrong accounts, which is a silent and confident kind of wrong, so it has its own tests including lookup-table entries.

Lamport deltas come from postBalances[i] - preBalances[i]. Token deltas come from the pre and post token balance arrays, matched on accountIndex and mint, held as BigInt so amounts above 2^53 stay exact. An account present only in postTokenBalances starts from zero, which is what a freshly created token account looks like.

Invoked programs are collected from the top-level compiledInstructions and from innerInstructions, so a program reached only through CPI is still enumerated. Inner instructions arrive in three shapes depending on the RPC: indexed (programIdIndex, numeric accounts, base58 data), keyed (programId, pubkey accounts, base58 data), or jsonParsed (program, programId, parsed.type, parsed.info, with no raw data at all). All three are handled. The public mainnet endpoint returns the jsonParsed shape, so for CPI-invoked instructions the tag detectors fall back to matching parsed.type and read the target account out of parsed.info.

SPL Token tag values

SPL Token instruction data begins with a single u8 tag. A wrong tag number means a detector silently never fires, which is worse than not having the detector at all, so the values were read off real encoded instructions rather than recalled. The create*Instruction helpers in @solana/spl-token were used to build one instruction per operation, and data[0] was printed:

operation tag
Transfer 3
Approve 4
Revoke 5
SetAuthority 6
MintTo 7
Burn 8
CloseAccount 9
FreezeAccount 10
ThawAccount 11
TransferChecked 12
ApproveChecked 13
BurnChecked 15
SyncNative 17

The same table applies to Token-2022, which shares the layout. For SetAuthority, data[1] is the authority type: 0 MintTokens, 1 FreezeAccount, 2 AccountOwner, 3 CloseAccount.

System Program instruction data begins with a u32 little-endian tag. These were read the same way from the @solana/web3.js SystemProgram helpers via data.readUInt32LE(0): CreateAccount 0, Assign 1, Transfer 2, AdvanceNonceAccount 4, WithdrawNonceAccount 5, InitializeNonceAccount 6, AuthorizeNonceAccount 7, Allocate 8, AllocateWithSeed 9, AssignWithSeed 10, TransferWithSeed 11.

Program IDs used: legacy SPL Token TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA, Token-2022 TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb.

test/tags.test.js pins every tag to a literal and separately re-derives it from the library encoders, and the detector tests assert that each tag fires its detector while the neighbouring tags do not. Changing any single tag in src/constants.js fails at least two tests, so the numbers are load-bearing rather than decorative. The literal assertions exist because a detector test that builds its fixture from the same constant it asserts on stays green when the constant is wrong.

Tests

npm test            # offline only, no network
npm run test:live   # adds read-only mainnet integration tests
npm run typecheck   # type-check the JavaScript through its JSDoc

Measured on Node v26.5.0 immediately before this README was committed. The counts move as tests are added, so treat them as a record of one run rather than a fixed property of the repository:

command result
npm test 122 tests, 121 pass, 0 fail, 1 skipped. The skip is the live suite, gated behind PRESIGN_LIVE=1.
npm run test:live 125 tests, 125 pass, 0 fail, 0 skipped, against api.mainnet-beta.solana.com.
npm run typecheck Exit 0, no diagnostics.

CI runs the offline suite, the type-check and the safety audit on Node 20.x and 22.x on every push. The live suite is left out of CI, because a public RPC endpoint is not a dependency a build should have.

The offline suite constructs real compiled transaction messages and pairs them with synthetic simulation results, so every detector is exercised without a network. It asserts both that detectors fire on the attack and that they stay quiet on the honest equivalent. The CLI tests additionally replay the recorded mainnet simulations in examples/ end to end, with PRESIGN_RPC pointed at a closed port, so a regression that reached the network would fail rather than pass quietly.

The live suite is guarded by PRESIGN_LIVE=1. It finds an already-funded system-owned account among the fee payers of a recent block, uses it as the simulation fee payer, and checks three verdicts against mainnet. Nothing is signed, sent or funded, and that account is not debited: sigVerify:false means the simulator does not need its key, and no transaction is ever broadcast. When the public RPC rate-limits or the network is unavailable the live tests skip rather than fail.

test/safety.test.js is the audit that backs the claim at the top of this README. It scans the whole source tree for anything that could broadcast a transaction or touch a private key, and asserts that src/ calls exactly one RPC method.

Limitations

Read these before trusting a verdict.

Simulation is fast feedback, not a guarantee

Simulation predicts effects against state at simulation time. The transaction lands later, against different state. A malicious program can read a clock, a slot number, an oracle or its own storage and behave one way under simulation and another way at execution. This is the simulate-benign, execute-malicious pattern, and no simulation-based check can rule it out.

What Presign catches is the far more common case: a transaction whose plain effects already disagree with what was declared. It does not defeat an adversary who controls a program inside the transaction. The intended answer is compiling intents into on-chain Lighthouse assertions, which are evaluated at execution, in the same transaction, and fail atomically. That is not built. See Planned, not built.

Per-transaction deltas cannot see cross-transaction drains

A delegate approved in one transaction and exercised in another shows no balance change in the transaction Presign inspects. token_delegate_approve exists precisely because the authority grant is visible even when the movement is not, but the general problem remains: Presign has no memory between transactions. The same gap covers salami slicing, where many individually in-budget transfers add up to a drain that no single transaction violates.

Token-2022 is flagged, not modelled

Transfer hooks can invoke arbitrary programs during a transfer. Transfer fees change the amount that actually arrives. A permanent delegate can move tokens without the owner. Confidential transfers hide amounts from balance arithmetic entirely. Presign detects that the Token-2022 program is involved and raises a WARN saying the delta arithmetic may be incomplete. It does not decode extension state or model those behaviours.

A recorded simulation is a point-in-time record

--sim exists so the worked examples above are reproducible offline and so CI can exercise the whole pipeline without a network. It replays effects that were true at the slot named in the file. Never gate a real signing decision on a recording.

Each recording carries the SHA-256 of the transaction it was taken against, and the CLI refuses to replay it beside any other transaction. Balance arrays are indexed against one specific account key list, and mixing two of them produces a confident verdict computed from nonsense.

Other known gaps

Presign models the fee payer at index 0 as the agent's own wallet and treats credits to it as inflows. Rent classification is a heuristic on credit size and prior non-existence, not a rent-exemption calculation. When inner instructions arrive in jsonParsed form there is no raw instruction data, so CPI-level tag detection depends on the validator's parsed type names, which could not be verified on chain the way the top-level tags were. Non-SPL programs are checked only by program identity and by their observable balance effects, because Presign does not decode arbitrary program instruction data. Instruction-level detectors beyond the ones tabled above do not exist, so a program can do something harmful that leaves no balance delta and is not on the list.

No audit, no production claim

This code has not been audited. It is a first milestone, written to be run and tested rather than to be relied on. There is no prior Solana track record behind it.

Files

src/constants.js   program IDs, verified instruction tag tables, severity and verdict enums
src/intent.js      intent schema, validation, normalisation
src/simulate.js    read-only simulation, account key list, effect extraction, retry with backoff
src/detectors.js   the detectors, each returning structured findings
src/findings.js    the finding registry the tests check this README against
src/presign.js     verdict engine and public API
bin/presign.js     CLI
examples/          crafted unsigned transactions, intents, and recorded simulations
test/              offline unit tests, the CLI replays, the safety audit, the gated live tests

Security and contributing

Security policy, including the read-only guarantee and how to report a bypass: SECURITY.md.

How to add a detector, and the testing rules a change has to meet: CONTRIBUTING.md.

License

MIT. See LICENSE.

About

A Solana agent-transaction firewall: simulates a transaction against live mainnet state, diffs the predicted effects against the declared intent, and blocks it before signing.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages