Skip to main content

SDK Overview

Install, configure, and operate the SpendSafe SDK.


Installation

Coming Soon

The SDK package will be available on npm soon. For now, local development requires workspace installation.


Initialising PolicyWallet

import { createEthersAdapter, PolicyWallet } from '@spendsafe/sdk';

export async function createPolicyWallet() {
const adapter = await createEthersAdapter(
process.env.PRIVATE_KEY!,
process.env.RPC_URL!
);

return new PolicyWallet(adapter, {
apiUrl: process.env.SPENDSAFE_API_URL!,
apiKey: process.env.SPENDSAFE_API_KEY!,
expectedPolicyHash: process.env.SPENDSAFE_POLICY_HASH, // optional
metadata: {
orgId: process.env.ORG_ID!,
walletId: 'my-wallet',
agentId: 'my-agent'
},
logLevel: process.env.NODE_ENV === 'production' ? 'error' : 'info',
});
}

Required Parameters

  • apiUrl – SpendSafe API base URL (e.g. http://localhost:3001 for local development)
  • apiKey – Required agent key; scopes policies and counters

Optional Parameters

  • expectedPolicyHash – SHA-256 of the active policy for integrity checks (fails closed if mismatch)
  • metadata – Object forwarded to API for analytics/audit logs (orgId, walletId, agentId, custom fields)
  • logLevel – Controls verbosity: 'silent' | 'error' | 'warn' | 'info' | 'debug' (default: 'info')
  • assetDecimals – Map of token symbol/address → decimals; overrides formatting for custom assets
  • fetch – Provide fetch implementation for Node 18- environments (Node 18+ includes global fetch)
  • logger – Supply custom logger (pino/winston) to redirect SDK logs

Configuration Reference

FieldTypeRequiredDescription
apiUrlstringSpendSafe API base URL
apiKeystringAgent authentication credential
expectedPolicyHashstringOptional SHA-256 for integrity verification
metadataobjectCustom fields for audit logs (orgId, walletId, agentId, etc.)
assetDecimalsObject (symbol → decimals)Custom decimal overrides per asset
fetchtypeof fetchFetch polyfill (required for Node 18-)
loggerLoggerCustom logger instance
logLevelLogLevelLog verbosity level

Note: The SDK requires a working fetch. Node 18+ exposes it globally; older runtimes must pass config.fetch.

⚠️ Deprecated: policyId parameter is deprecated (policy group now derived from API key server-side). Remove from new integrations.


Sending a Transaction

const wallet = await createPolicyWallet();

try {
const result = await wallet.send({
chain: 'ethereum', // lowercase: 'ethereum', 'base', etc.
asset: 'eth', // lowercase: 'eth', 'usdc', 'dai', etc.
to: '0xRecipient...',
amount: '25000000000000000', // ALWAYS string in base units (wei)
memo: 'Invoice #982', // optional, included in intent fingerprint
});

console.log('Hash:', result.hash);
console.log('Fee (wei):', result.fee.toString());
console.log('Allowed:', result.allowed);
if (result.counters) {
console.log('Counters:', result.counters);
}
if (result.decisionProof) {
console.log('Decision proof:', result.decisionProof);
}
} catch (error) {
if (error instanceof PolicyError) {
console.error(error.code, error.message);
console.error('Counters:', error.details?.counters);
console.error('Decision proof:', error.details?.decisionProof);
} else {
throw error;
}
}

⚠️ CRITICAL: Amount Format

Amounts MUST be strings in base units (smallest denomination):

  • ETH: "1000000000000000000" = 1 ETH (18 decimals)
  • USDC: "1000000" = 1 USDC (6 decimals)
  • DAI: "1000000000000000000" = 1 DAI (18 decimals)

The SDK validates this format and throws INVALID_AMOUNT_FORMAT for non-string values.

SendIntent Structure

interface SendIntent {
chain: string; // Chain identifier (lowercase): 'ethereum', 'base', 'base-sepolia', etc.
asset: string; // Asset identifier (lowercase): 'eth', 'usdc', 'dai', 'wbtc', etc.
to: string; // Recipient address
amount: string; // Amount in base units (wei/smallest unit) as string
memo?: string; // Optional memo (included in intent fingerprint via SHA-256)
}

SendResult Structure

interface SendResult {
hash: string; // Transaction hash
fee: bigint; // Gas fee paid (in base units, e.g. wei)
allowed: boolean; // Whether transaction was policy-approved
counters?: PolicyCounters; // Optional: Live spending counters (if available)
decisionProof?: DecisionProof; // Optional: Decision proof components (if available)
}

See TypeScript Interface Reference below for complete PolicyCounters and DecisionProof structures.

Note: result.fee is the gas fee paid for the transaction (in wei for EVM chains), not the transaction amount.

Fail-Closed Behaviour

PolicyWallet.send fails closed—no signing occurs until both policy gates succeed. If Gate 1, Gate 2, or blockchain broadcast fails, the SDK throws an error and never signs the transaction.


Error Codes

Policy Errors

CodeMeaningNext Step
POLICY_DECISION_DENYPolicy blocked the transferCheck counters; adjust limits or whitelist
POLICY_DECISION_REVIEWManual review required (future)Wait for human approval
POLICY_DECISION_UNKNOWNUnrecognised decision from policy APICheck API logs; contact support
POLICY_HASH_MISMATCHDashboard hash ≠ SDK expectationUpdate expectedPolicyHash in config
POLICY_HASH_MISSINGGate 1 didn't return policy hashCheck API implementation

Authentication Errors

CodeMeaningNext Step
AUTH_INVALIDGate 2 rejected authorisation tokenToken tampered or invalid; retry send
AUTH_EXPIREDToken expired (>60s since Gate 1)Generate fresh token via new send
AUTH_USEDToken already consumed (replay attempt)Generate fresh token via new send
AUTH_MISMATCHIntent fingerprint mismatchTampering detected; investigate
AUTH_TOKEN_MISSINGGate 1 didn't return auth tokenCheck API implementation

Validation Errors

CodeMeaningNext Step
VALIDATION_ERRORGate 1/2 validation failedCheck request format
INTENT_FINGERPRINT_MISMATCHIntent modified between gatesTampering detected; investigate
INVALID_AMOUNT_TYPEAmount not provided as stringFix: amount: "1000000" (string)
INVALID_AMOUNT_EMPTYAmount is empty stringProvide valid amount
INVALID_AMOUNT_FORMATAmount contains non-numeric charsUse only digits: "1000000"
INVALID_INTENT_FIELDRequired field missing/invalidCheck SendIntent structure

Network/System Errors

CodeMeaningNext Step
NETWORK_ERRORNetwork request failedCheck API connectivity; retry
FETCH_UNAVAILABLENo fetch implementation foundProvide config.fetch for Node 18-
VERIFICATION_ERRORGate 2 verification failedCheck logs; retry
UNKNOWN_ERRORUnexpected failureInspect error details; contact support

Error Object Structure

class PolicyError extends Error {
code: string; // Error code from table above
message: string; // Human-readable description
details?: {
counters?: PolicyCounters; // Current spending state (if available)
decisionProof?: DecisionProof; // Decision proof (if available)
};
}

See TypeScript Interface Reference below for complete PolicyCounters and DecisionProof structures.


Decision Proofs

Successful wallet.send() responses include decision proof components:

{
"policyHash": "ba500a3fee18ba269cd...",
"decision": "allow",
"signature": "0x...",
"signatureIssuedAt": "2025-02-14T12:34:56Z",
"apiKeyId": "api_key_123",
"intentFingerprint": "7c5b..."
}

Store proofs for:

  • Compliance reporting
  • Audit trail reconciliation
  • Dashboard log verification

Retrieving Last Decision Proof

// Get last decision proof without making new transaction
const proof = wallet.getLatestDecisionProof();
if (proof) {
console.log('Last decision:', proof.decision);
console.log('Policy hash:', proof.policyHash);
}

Helper Methods

Access Underlying Adapter

// Get direct access to wallet adapter
const adapter = wallet.getAdapter();
const balance = await adapter.getBalance();
console.log('Balance:', balance.toString());

Get Wallet Address

// Get wallet address
const address = await wallet.getAddress();
console.log('Address:', address);

Get Wallet Balance

// Get native token balance (e.g. ETH)
const balance = await wallet.getBalance();
console.log('Balance:', balance.toString(), 'wei');

Adapters

  • ethers – Default, broad ecosystem support
  • viem – Modern TypeScript-first option (bundled)
  • Solana – Includes lamports helper utilities (bundled)
  • Coinbase, Dynamic, Privy – Embedded or custodial options

All adapters implement the WalletAdapter interface defined in the SDK (packages/sdk/src/adapters/types.ts). You can create custom adapters by implementing these methods.


Advanced Topics

Automatic Features

The SDK automatically handles:

  • Nonce generation – Unique nonce (UUID) generated per request to prevent fingerprint collisions
  • Idempotency keys – Automatic idempotencyKey generation to prevent duplicate transactions on retry
  • Single-use tokens – Authorization tokens consumed at Gate 2, preventing replay attacks
  • Intent fingerprinting – SHA-256 hash of canonicalized intent for tamper detection

Memo Usage

The memo field is optional but useful for:

  • Correlation IDs (invoice numbers, order IDs)
  • Audit trail context
  • Customer reference numbers
const result = await wallet.send({
chain: 'ethereum',
asset: 'usdc',
to: '0xCustomer...',
amount: '5000000', // 5 USDC
memo: 'Refund for order #12345'
});

The memo is included in the intent fingerprint (as SHA-256 hash) and appears in audit logs.

Retry Behaviour

Important: Once Gate 1 succeeds, spending counters are updated immediately (hard reservation). If Gate 2 or blockchain broadcast fails afterward, the transaction still consumes your budget.

For failed transactions after Gate 1:

  • Counters remain updated (not rolled back)
  • Generate new transaction to retry
  • Budget already consumed

This prevents race conditions and "double spending" via concurrent requests.

Transaction Lifecycle

  1. SDK generates nonce + idempotency key
  2. Gate 1 evaluates policy → reserves amount → issues token
  3. Gate 2 verifies token + fingerprint → marks consumed
  4. SDK signs transaction locally with your keys
  5. SDK broadcasts to blockchain
  6. SDK returns hash + fee + counters + decision proof

If any step fails, SDK throws error and does not proceed to next step.


Integration Tips

  • Use memo for correlation IDs – Intent fingerprint includes SHA-256 hash of memo
  • Store decision proofs – Reconcile SDK responses with dashboard audit logs
  • Handle errors gracefully – Check error.details.counters to diagnose policy blocks
  • Rotate API keys – When agent roles change, only latest key remains valid
  • Test with small amounts – Use testnet/sepolia for integration testing

TypeScript Interface Reference

Complete type definitions from the SDK (packages/sdk/src/types.ts).

PolicyCounters

export interface PolicyCounters {
todaySpent?: string; // Amount spent today (optional, in base units)
remainingDaily?: string; // Remaining daily budget (optional, in base units)
[key: string]: string | undefined; // Extensible for custom counters
}

Note: All fields are optional. The API may return additional counter fields beyond todaySpent and remainingDaily (e.g. hourly counters, transaction counts). Use index signature [key: string] to access them.

DecisionProof

export interface DecisionProof {
policyHash: string; // Required: SHA-256 hash of active policy
decision: 'allow' | 'deny' | 'review'; // Required: Policy decision
intentFingerprint: string; // Required: SHA-256 hash of transaction intent
signature?: string; // Optional: HMAC signature of decision
signatureIssuedAt?: string; // Optional: ISO timestamp when signature issued
apiKeyId?: string; // Optional: API key that authorised the decision
}

Note: Only policyHash, decision, and intentFingerprint are guaranteed. Other fields (signature, signatureIssuedAt, apiKeyId) may be absent depending on API configuration.

PolicyMetadata

export interface PolicyMetadata {
orgId?: string;
walletId?: string;
agentId?: string;
[key: string]: string | undefined; // Extensible for custom metadata
}

Usage: Custom metadata fields forwarded to the policy API for analytics and audit logs. All fields are optional and user-defined.

SendIntent

export interface SendIntent {
chain: string; // Chain identifier (lowercase): 'ethereum', 'base', 'base-sepolia', etc.
asset: string; // Asset identifier (lowercase): 'eth', 'usdc', 'dai', 'wbtc', etc.
to: string; // Recipient address
amount: string; // Amount in base units (wei/smallest unit) as string
memo?: string; // Optional memo (included in intent fingerprint via SHA-256)
}

SendResult

export interface SendResult {
hash: string; // Transaction hash
fee: bigint; // Gas fee paid (in base units, e.g. wei)
allowed: boolean; // Whether transaction was policy-approved
counters?: PolicyCounters; // Optional: Live spending counters
decisionProof?: DecisionProof; // Optional: Decision proof components
}

PolicyError

export class PolicyError extends Error {
code: string; // Error code (see Error Codes section)
message: string; // Human-readable description
details?: {
counters?: PolicyCounters; // Current spending state (if available)
decisionProof?: DecisionProof; // Decision proof (if available)
};
}

Next Steps