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.
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.
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.
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.
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.
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"]
}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.
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.
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.
{
"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.
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.
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. |
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 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.
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.
Read these before trusting a verdict.
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.
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.
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.
--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.
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.
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.
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 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.
MIT. See LICENSE.