Dexter
Dexter
Docs
Wallet & identity

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.

npm install @dexterai/vault

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

propose.ts
import { requestSpendGrant, encodeSpendGrantRequest } from '@dexterai/vault/grant';
 
// App side: describe the tab you want the user to open.
const blob = requestSpendGrant({
  app: {
    name: 'Acme Research',
    domain: 'acme.example',
    iconUrl: 'https://acme.example/icon.png',
  },
  counterparty: 'SoLSe11erAddre5sBase58xxxxxxxxxxxxxxxxxxxxxxx', // seller settlement address
  capAtomic: '20000000',          // 20 USDC, 6dp
  expiresAtUnix: 1793000000,
  requestId: 'order-8842',
});
 
const reqParam = encodeSpendGrantRequest(blob); // base64url — send as ?req= to the consent page

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.ts
import { PublicKey } from '@solana/web3.js';
import { parseSpendGrantRequest, approveSpendGrant } from '@dexterai/vault/grant';
 
async function consent(
  rawBlob: unknown,
  vaultPda: PublicKey,
  sign: (m: Uint8Array) => Promise<Uint8Array>,
) {
  const request = parseSpendGrantRequest(rawBlob); // hardens untrusted input
 
  const approved = await approveSpendGrant({
    request,
    vaultPda,                          // the USER's vault, from their identity — never the blob
    sign,                              // receives the exact 188 bytes the passkey must endorse
    edits: { capAtomic: '10000000' },  // shorten-only: user lowered the cap to 10 USDC
  });
 
  approved.message;        // Uint8Array — the 188-byte registration message that was signed
  approved.params;         // ApprovedSpendGrantParams — what the sponsor endpoint consumes
  approved.shortened.cap;  // true here
  approved.sessionKeypair; // generated keypair (custody mode i) or null
  return approved.params;
}

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.

node-signer.ts
import { NodeEd25519Signer } from '@dexterai/vault/signers/node';
 
// 32-byte seed OR 64-byte nacl secret key are both accepted.
const seed = new Uint8Array(32);
const signer = new NodeEd25519Signer(seed);
 
const pub: Uint8Array = signer.publicKey;          // 32-byte ed25519 public key
async function signVoucher(message: Uint8Array): Promise<Uint8Array> {
  return signer.sign(message);                     // 64-byte detached signature
}

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(operationMessage: Uint8Array): Promise<{
  signature: Uint8Array;         // 64-byte compact r||s, low-S normalized (SIMD-0075)
  clientDataJSON: Uint8Array;    // raw, what the authenticator hashed
  authenticatorData: Uint8Array; // raw, what the authenticator signed
}>

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 credentialId plus a ServerPolicy.
  • guest — no-account anon flow, keyed on a server-minted userHandle plus an AnonServerPolicy. This is the keying Sign in with Dexter hands you as passkeySigner.

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.

register-message.ts
import { PublicKey } from '@solana/web3.js';
import { sessionRegisterMessage } from '@dexterai/vault/messages';
 
const registerMsg: Uint8Array = sessionRegisterMessage({
  programId: new PublicKey('Hg3wRaydFtJhYrdvYrKECacpJYDsC9Px7yKmpncj2fhc'),
  vaultPda: new PublicKey('11111111111111111111111111111111'),
  sessionPubkey: new Uint8Array(32),
  maxAmount: 20_000_000n,
  expiresAt: 1793000000n,
  allowedCounterparty: new PublicKey('11111111111111111111111111111111'),
  nonce: 1,
  maxRevolvingCapacity: 20_000_000n,
});
// registerMsg.length === 188

The V2 layout:

OffsetBytesField
032domain separator (OTS_SESSION_REGISTER_V2)
3232program_id
6432vault_pda
9632session_pubkey
1288max_amount (u64 LE)
1368expires_at (i64 LE)
14432allowed_counterparty
1764nonce (u32 LE)
1808max_revolving_capacity (u64 LE)
188total

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.

register-ix.ts
import { PublicKey } from '@solana/web3.js';
import { buildRegisterSessionKeyInstruction } from '@dexterai/vault/instructions';
 
const ix = buildRegisterSessionKeyInstruction({
  vaultPda: new PublicKey('11111111111111111111111111111111'),
  sessionPubkey: new Uint8Array(32),
  maxAmount: 20_000_000n,
  expiresAt: 1793000000n,
  allowedCounterparty: new PublicKey('11111111111111111111111111111111'),
  nonce: 1,
  maxRevolvingCapacity: 20_000_000n,
  swigAddress: new PublicKey('11111111111111111111111111111111'),
  vaultUsdcAta: null,       // own-USDC counted as 0 when the ATA does not exist yet
  payer: new PublicKey('11111111111111111111111111111111'),
  siblingSessionPdas: [],   // fetch FRESH via fetchVaultSessionAccounts before building
  clientDataJSON: new Uint8Array(),
  authenticatorData: new Uint8Array(),
});

clientDataJSON and authenticatorData come from the passkey assertion over the 188-byte message. Two fields need care:

  • siblingSessionPdas — every other SessionAccount PDA of this vault whose version is not zero (live and expired-unswept), target excluded. Fetch it fresh with fetchVaultSessionAccounts from @dexterai/vault/session immediately before building; the builder excludes, dedups, sorts, and marks them writable. A stale set reverts the program. The happy path is sessionPdasOf(await fetchVaultSessionAccounts(conn, vaultPda)).
  • vaultUsdcAta — the swig wallet's USDC token account, read live for the overcommit gate. Pass null when the account does not exist on-chain yet; own-USDC is then counted as 0. Use resolveVaultUsdcAta to 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.

revoke.ts
import { PublicKey } from '@solana/web3.js';
import { sessionRevokeMessage } from '@dexterai/vault/messages';
import { buildRevokeSessionKeyInstruction } from '@dexterai/vault/instructions';
 
const programId = new PublicKey('Hg3wRaydFtJhYrdvYrKECacpJYDsC9Px7yKmpncj2fhc');
const vaultPda = new PublicKey('11111111111111111111111111111111');
 
// 1. Build the 128-byte message the passkey signs.
const revokeMsg: Uint8Array = sessionRevokeMessage({
  programId,
  vaultPda,
  sessionPubkey: new Uint8Array(32), // read from the LIVE session account (fetchSessionAccount)
});
// revokeMsg.length === 128
 
// 2. After the assertion over that message, build the instruction.
const ix = buildRevokeSessionKeyInstruction({
  vaultPda,
  allowedCounterparty: new PublicKey('11111111111111111111111111111111'),
  clientDataJSON: new Uint8Array(),
  authenticatorData: new Uint8Array(),
});

The 128-byte layout:

OffsetBytesField
032domain separator (REVOKE_DOMAIN)
3232program_id
6432vault_pda
9632session_pubkey
128total

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.

On this page