Verified against @dexterai/x402 5.3.1 · 2026-07-07
Vault engine reference
By the end of this page you will know what @dexterai/vault is for, how the spend-grant ceremony turns a user's consent into a signed on-chain message, the two signer types the engine ships, and how to build the register and revoke instructions byte-for-byte.
This is the engine, not the front door
@dexterai/vault is the off-chain mirror of the on-chain dexter-vault Anchor program. It ships byte-precise instruction builders, message encoders, account decoders, and signer abstractions. It is the layer three services already share: dexter-api, dexter-facilitator, and the vault program's own tests.
Most apps do not import this package directly. If you want to give an agent a spending limit, reach for @dexterai/x402, which depends on this package transitively and hands you the buyer and seller sides in a few lines. Come here when you assemble your own vault transactions: a facilitator, a custom settlement path, or a second Open Tabs implementation.
The installed version is 0.34.0. It targets the V6 program with per-counterparty SessionAccount PDAs. V5 vaults are not decodable by this line; migrate them with the migrate_v5_to_v6 builders.
Exports are split by subpath: @dexterai/vault/grant, /signers/node, /signers/browser, /messages, /instructions, /session, /tab, /reader, and more. Import from the subpath, not a barrel.
Spend grants
A spend grant is how an app asks a user to open a tab, and how the user's consent becomes a signed on-chain registration. It is a two-sided ceremony. The app proposes; the user's consent surface approves and signs.
App side: propose
requestSpendGrant returns a validated SpendGrantRequest blob. encodeSpendGrantRequest renders it to a base64url string you hand to the consent page as a ?req= parameter; decodeSpendGrantRequest reverses it.
The blob is untrusted display input. Trust pins on the counterparty address — it is the on-chain binding and the session PDA seed. The blob carries no vaultPda (the vault is the user's, resolved from their own identity — an app-supplied one is a phishing surface) and no nonce (the consent page chooses it at ceremony time).
SpendGrantProposed fields the user may only shorten: capAtomic (u64 string, USDC 6dp), expiresAtUnix (unix seconds), and the optional revolvingCapacityAtomic.
Consent side: approve and sign
parseSpendGrantRequest hardens the blob (JSON string or already-parsed object). approveSpendGrant applies the user's shorten-only edits, resolves session-key custody, builds the byte-exact 188-byte registration message, runs your injected sign function over it, and returns { message, params, ceremony, sessionKeypair, shortened }.
Edits are shorten-only. Raising the cap or extending the expiry throws GrantEditError. The sign seam receives the exact bytes the passkey must endorse — in the browser you pass your WebAuthn pipeline; in tests you pass a recorder.
approveSpendGrant ends at the signed grant on purpose. It does not build the register instruction. The V6 sibling-account set must be fetched fresh immediately before the instruction is built and sent, and the sponsor is the fee payer anyway. If you pay your own rent, build the instruction yourself with buildRegisterSessionKeyInstruction (below).
ApprovedSpendGrantParams is what the sponsor endpoint consumes: counterparty, sessionPubkey, maxAmountAtomic, expiresAtUnix, nonce, maxRevolvingCapacityAtomic.
Signer model
The engine defines two signer shapes. Which one you use is set by where the key lives.
NodeEd25519Signer — server-held keys
NodeEd25519Signer wraps an ed25519 secret with tweetnacl. dexter-api uses it as its session-master signer; tests use it as a deterministic stand-in.
The constructor accepts either a 32-byte seed or a 64-byte nacl secret key (seed concatenated with pubkey). publicKey is 32 bytes; sign returns a 64-byte detached signature.
The passkey story — P-256 in the browser
The user's own keys are passkeys, not ed25519. A passkey is a P-256 (secp256r1) credential in the OS keychain, and it never signs a raw voucher — it signs on-chain operation messages through a WebAuthn assertion the on-chain program verifies with a secp256r1 precompile.
@dexterai/vault/signers/browser ships DexterApiBrowserPasskeySigner, the canonical browser signer. It has one honest method:
signOperation hashes the operation message internally (opHash = sha256(op)), mints a server challenge bound to that hash, runs the WebAuthn assertion over it, and returns the three on-chain-ready byte fields. The binding is the on-chain law: clientDataJSON.challenge === sha256(operationMessage). That is the replay defense — the signature is bound to one exact operation.
It exposes the 33-byte SEC1 compressed P-256 publicKey eagerly (the form the vault stores), so the tab adapter can build the secp256r1 precompile without a server round-trip.
The signer has two keyings behind that one method:
- auth — logged-in flow, keyed on
credentialIdplus aServerPolicy. - guest — no-account anon flow, keyed on a server-minted
userHandleplus anAnonServerPolicy. This is the keying Sign in with Dexter hands you aspasskeySigner.
The interface file also declares a bare PasskeySigner and a low-level WebAuthnAssertion driver (a pure-browser navigator.credentials.get() over a raw challenge, no policy). Those are the ceremony primitives. Prefer DexterApiBrowserPasskeySigner.signOperation — it is the policy-wrapped surface that both keyings honor, so a consumer can never be handed a method that throws.
The register message
sessionRegisterMessage builds the exact 188 bytes the passkey endorses when a session is registered. It must match the on-chain build_registration_message byte-for-byte.
The V2 layout:
| Offset | Bytes | Field |
|---|---|---|
| 0 | 32 | domain separator (OTS_SESSION_REGISTER_V2) |
| 32 | 32 | program_id |
| 64 | 32 | vault_pda |
| 96 | 32 | session_pubkey |
| 128 | 8 | max_amount (u64 LE) |
| 136 | 8 | expires_at (i64 LE) |
| 144 | 32 | allowed_counterparty |
| 176 | 4 | nonce (u32 LE) |
| 180 | 8 | max_revolving_capacity (u64 LE) |
| 188 | total |
Build the register instruction
If you pay your own rent, build the instruction directly. The instruction's Borsh args mirror the message fields, plus the account inputs the program needs.
clientDataJSON and authenticatorData come from the passkey assertion over the 188-byte message. Two fields need care:
siblingSessionPdas— every otherSessionAccountPDA of this vault whose version is not zero (live and expired-unswept), target excluded. Fetch it fresh withfetchVaultSessionAccountsfrom@dexterai/vault/sessionimmediately before building; the builder excludes, dedups, sorts, and marks them writable. A stale set reverts the program. The happy path issessionPdasOf(await fetchVaultSessionAccounts(conn, vaultPda)).vaultUsdcAta— the swig wallet's USDC token account, read live for the overcommit gate. Passnullwhen the account does not exist on-chain yet; own-USDC is then counted as 0. UseresolveVaultUsdcAtato derive-and-probe it in one call.
The revoke message
Revoke invalidates one session. The message is 128 bytes and must match the on-chain build_revocation_message.
The 128-byte layout:
| Offset | Bytes | Field |
|---|---|---|
| 0 | 32 | domain separator (REVOKE_DOMAIN) |
| 32 | 32 | program_id |
| 64 | 32 | vault_pda |
| 96 | 32 | session_pubkey |
| 128 | total |
The session_pubkey in the revoke message must be read from the live SessionAccount PDA (fetchSessionAccount from @dexterai/vault/session), not a value you kept from registration. That is the replay protection: a revocation signed against an old session_pubkey cannot kill a rotated session, because the handler rebuilds the message from the pubkey the PDA currently holds and the two must match.
For the browser flow where a session may already be live, @dexterai/vault/session also ships composeRevokeThenRegister — the atomic revoke-then-register primitive that fetches the sibling set fresh, prepends the revoke pair when a live session exists, and register-only when it does not.