@crossmint/solana-smart-wallet-sdk
v0.23.0
Published
TypeScript SDK for Solana Smart Wallet program
Readme
@solana-smart-account/sdk
TypeScript SDK for the Solana Smart Account program. Create wallets, manage signers, set spending limits, execute transactions (sync and async), and manage M-of-N multisig signers through a PDA-based smart account.
Install
npm install @solana-smart-account/sdkPeer dependencies: @coral-xyz/anchor >=0.30.0 and @solana/web3.js >=1.90.0.
Quick Start
The IDL is bundled with the SDK — no need to import it separately.
import {
SmartAccountClient,
adminSigner,
ed25519Key,
fromTransactionInstruction,
} from "@solana-smart-account/sdk";
import { SystemProgram } from "@solana/web3.js";
const client = SmartAccountClient.create(provider);
const signer = adminSigner(ed25519Key(myKeypair.publicKey));
// Create the smart account on-chain
const { signature, pdas } = await client.createSmartAccountAndSend({
salt: 42,
signers: [signer],
});
// Execute a SOL transfer through the wallet
const transferIx = SystemProgram.transfer({
fromPubkey: pdas.smartAccountPda,
toPubkey: recipient,
lamports: 1_000_000,
});
const result = await client.executeTransaction({
settingsPda: pdas.settingsPda,
withSigner: ed25519Key(myKeypair.publicKey),
instructions: [fromTransactionInstruction(transferIx)],
});
await client.send(result.transaction);Execution Model
All transaction execution follows a unified build → approvals → finalize → send pattern, regardless of signer type.
- Build: call
.build()on the fluent builder (orexecuteTransaction()). Returns aBuildResultwith{ transaction, approvals }. - Approvals: for Ed25519 signers,
approvalsis empty — the transaction itself carries the signature. For Secp256r1/WebAuthn signers and P256 multisig members,approvalscontains the messages to sign externally (WebAuthn, HSM, MPC, etc.). - Finalize: call
client.finalizeTransaction(result, signatures)to splice the external signatures into the transaction. Skip this step for Ed25519-only flows. - Send: call
client.send(tx)to broadcast.
Fluent builder
const result = await client
.execute()
.settingsPda(pdas.settingsPda)
.withSigner(ed25519Key(myKeypair.publicKey))
.instruction(fromTransactionInstruction(transferIx))
.instruction(fromTransactionInstruction(swapIx))
.build();
await client.send(result.transaction);.withSigner(key) accepts any SignerKey — ed25519Key(...), secp256r1Key(...), or multisigKey(...). The SDK resolves the signer index and sets up replay prevention automatically.
Ed25519 execution
Ed25519 signers produce no approvals — just sign and send:
const result = await client.executeTransaction({
settingsPda,
withSigner: ed25519Key(keypair.publicKey),
instructions: [fromTransactionInstruction(transferIx)],
});
await client.send(result.transaction);Or with the builder:
const result = await client
.execute()
.settingsPda(pda)
.withSigner(ed25519Key(keypair.publicKey))
.instruction(fromTransactionInstruction(transferIx))
.build();
await client.send(result.transaction);Secp256r1 / WebAuthn execution
P256 signers require on-chain replay prevention. Use .nonce(n) for the built-in nonce or configure a wallet RPP and then call .rpp() for RPP-based per-signer isolation.
const result = await client
.execute()
.settingsPda(pda)
.withSigner(secp256r1Key(passkey))
.instruction(fromTransactionInstruction(transferIx))
.nonce(42) // or .rpp() after configuring rppProgramId on the wallet
.build();
// result.approvals contains the messages to sign externally
const signatures = await Promise.all(
result.approvals.map(async (a) => ({
publicKey: a.publicKey,
signature: await sign(a.message), // WebAuthn, HSM, MPC, etc.
}))
);
const tx = client.finalizeTransaction(result, signatures);
await client.send(tx);Multisig execution
Multisig signers may have a mix of Ed25519 and Secp256r1 members. Ed25519 members sign the transaction directly; Secp256r1 members produce approvals.
const result = await client
.execute()
.settingsPda(pda)
.withSigner(
multisigKey(2, [
ed25519Member(kp1.publicKey),
secp256r1Member(pk2),
secp256r1Member(pk3),
])
)
.instruction(fromTransactionInstruction(transferIx))
.build();
// Ed25519 members sign the transaction directly
result.transaction.sign([kp1]);
// Secp256r1 members sign via approvals
const sigs = await Promise.all(
result.approvals.map(async (a) => ({
publicKey: a.publicKey,
signature: await signP256(a.message),
}))
);
const tx = client.finalizeTransaction(result, sigs);
await client.send(tx);Dry run / simulate
const dryRun = await client
.execute()
.settingsPda(pda)
.withSigner(ed25519Key(keypair.publicKey))
.instruction(transferIx)
.simulate();
console.log(dryRun.success, dryRun.unitsConsumed);Address Lookup Tables
When transactions reference many accounts they may exceed Solana's 1232-byte transaction size limit. Use Address Lookup Tables (ALTs) to compress account keys:
const altAccount = await connection.getAddressLookupTable(altAddress);
const result = await client
.execute()
.settingsPda(pdas.settingsPda)
.withSigner(secp256r1Key(passkey))
.instruction(complexSwapIx)
.addressLookupTables([altAccount.value!])
.build();If a transaction exceeds 1232 bytes, the SDK throws a TransactionTooLarge error with the actual size and a suggestion to use .addressLookupTables().
Builder Pattern
The SDK is designed around instruction builders — methods that return TransactionInstruction objects you can include in any transaction. The *AndSend convenience methods are thin wrappers around these builders.
Instruction builders (primary API)
Every operation has a builder method that returns a TransactionInstruction:
// Build the instruction — do not send yet
const { instruction, pdas } = await client.createSmartAccount({
salt,
signers,
});
// Combine with other instructions in your own transaction
const tx = new Transaction().add(computeBudgetIx, instruction);
await provider.sendAndConfirm(tx, [payer]);Convenience *AndSend methods
Each builder method has a *AndSend counterpart that builds and sends in one call. These are useful for quick scripts or simple flows, but use the builder methods when you need to:
- Compose instructions into a larger transaction
- Add compute budget instructions
- Sign with multiple keypairs
- Control transaction building precisely
Counterfactual Addresses
The wallet address is a PDA derived from sha256(salt || borsh(signers)). You can compute it offline and send funds to it before ever calling createSmartAccount. The address is deterministic and collision-resistant: same config = same address, different config = different address.
import {
computeConfigHash,
deriveSmartAccountAddress,
adminSigner,
ed25519Key,
} from "@solana-smart-account/sdk";
const signers = [adminSigner(ed25519Key(userPubkey))];
const configHash = computeConfigHash(salt, signers);
const { smartAccountPda } = deriveSmartAccountAddress(configHash);
// smartAccountPda can receive SOL/tokens now, deploy laterThe salt parameter lets you create multiple wallets with the same signer set.
Spending Limits
Standard signers can be restricted to per-mint spend caps over a time window. Admins set limits via updateSignerPolicies; the program enforces them at execution time.
Setting limits
import {
standardSigner,
ed25519Key,
PublicKey,
} from "@solana-smart-account/sdk";
const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
// Allow a Standard signer to spend up to 100 USDC per day
await client.updateSignerPoliciesAndSend({
settingsPda: pdas.settingsPda,
signerKey: ed25519Key(standardSignerPubkey),
limits: [
{
kind: "Token",
mint: USDC_MINT,
limitAmount: 100_000_000n, // 100 USDC in base units (6 decimals)
windowSeconds: 86_400n, // 1 day in seconds
currentSpent: 0n, // managed by program
windowStartTimestamp: 0n, // managed by program
allowedRecipients: [],
},
],
});To remove all limits and make a signer unrestricted, pass limits: [].
Native SOL limits
Use the Sol variant (kind: "Sol") to limit native SOL outflows:
{
kind: "Sol",
limitAmount: 1_000_000_000n, // 1 SOL in lamports
windowSeconds: 0n, // lifetime cap — never resets
currentSpent: 0n,
windowStartTimestamp: 0n,
allowedRecipients: [],
}How enforcement works
Spending limits are a per-signer property — each Standard signer has its own independent set of limits. The program enforces them outcome-based: it snapshots balances before CPI execution, runs the instructions, then checks the delta against each limit.
The token accounts referenced by your CPI instructions (e.g., the source ATA in a token transfer) are automatically included in remaining_accounts by the SDK. The program discovers them there and snapshots their balances — no extra configuration needed.
Enforcement rules:
- Any asset (SOL or token) that decreases must have a matching spending limit on the signer
- Unlisted asset decreases are rejected (
OperationNotPermitted) - Token account state changes beyond the balance field (Approve, Freeze, SetAuthority) are blocked
- Windows auto-reset: once
current_timestamp >= window_start_timestamp + window_seconds, the window resets on the next transaction windowSeconds = 0creates a lifetime cap that never resets
Multisig Signers
The program supports M-of-N multisig signer slots where each member can be either Ed25519 or Secp256r1. All member proofs must be submitted in a single on-chain transaction — there is no proposal state or async voting.
Creating a multisig signer
import {
multisigKey,
ed25519Member,
adminSigner,
} from "@solana-smart-account/sdk";
// 2-of-3 multisig: any two of these three keypairs can authorize
const key = multisigKey(2, [
ed25519Member(keypair1.publicKey),
ed25519Member(keypair2.publicKey),
ed25519Member(keypair3.publicKey),
]);
const { pdas } = await client.createSmartAccountAndSend({
salt: 42,
signers: [adminSigner(key)],
});Executing with a multisig
Use the unified fluent builder with .withSigner(multisigKey(...)). See the Multisig execution example above.
Multisig helpers
| Helper | Description |
| -------------------- | ---------------------------------------------------------------- |
| multisigKey(t,m) | Create a multisig SignerKey with threshold t and members m |
| ed25519Member(k) | Create an Ed25519 member from a PublicKey or bytes |
| secp256r1Member(k) | Create a Secp256r1 member from compressed/uncompressed key |
API Reference
SmartAccountClient
Create via SmartAccountClient.create(provider) (recommended) or new SmartAccountClient({ provider, program }) for full control.
Account Creation
| Method | Returns | Description |
| ----------------------------------- | ----------------------- | ------------------------------ |
| createSmartAccount(params) | { instruction, pdas } | Build the creation instruction |
| createSmartAccountAndSend(params) | { signature, pdas } | Create and send in one call |
Params: { salt: number, signers: SmartAccountSigner[], payer?: PublicKey }
Signer Management
| Method | Returns | Description |
| ----------------------------- | ------------------------ | ------------------------------- |
| addSigner(params) | TransactionInstruction | Build add-signer instruction |
| addSignerAndSend(params) | string (signature) | Add signer and send |
| removeSigner(params) | TransactionInstruction | Build remove-signer instruction |
| removeSignerAndSend(params) | string (signature) | Remove signer and send |
Caller must be a registered Admin. Cannot remove the last Admin.
Spending Limit Policies
| Method | Returns | Description |
| ------------------------------------- | ------------------------ | --------------------------------- |
| updateSignerPolicies(params) | TransactionInstruction | Build update-policies instruction |
| updateSignerPoliciesAndSend(params) | string (signature) | Update policies and send |
Params: { settingsPda, signerKey, limits: SpendingLimit[], payer? }
- Caller must be a registered Admin.
- Target signer must be Standard (policies cannot be set on Admin signers).
- Replaces all existing limits atomically. Pass
limits: []to remove all restrictions. currentSpentandwindowStartTimestampin the policy objects you pass are ignored — the program resets them.- Maximum policies per signer is capped (see
MAX_POLICIES_PER_SIGNERinconstants.rs).
To inspect current spend state, fetch the settings account with getSmartAccountInfo() and read signer.role.standard.limits.
Transaction Execution
| Method | Returns | Description |
| ---------------------------- | --------------------------- | --------------------------------------------------------------------------------- |
| execute() | ExecuteTransactionBuilder | Fluent builder — chain .settingsPda(), .withSigner(), .instruction(), .build() |
| executeTransaction(params) | Promise<BuildResult> | Build execute result directly |
| finalizeTransaction(result, signatures) | Transaction | Splice external Secp256r1 signatures into a built transaction |
| send(tx) | Promise<string> | Broadcast a transaction and return the signature |
executeTransaction and the fluent execute() builder both return a BuildResult:
type BuildResult = {
transaction: Transaction;
approvals: Approval[];
};
type Approval = {
publicKey: Uint8Array; // compressed Secp256r1 public key
message: Uint8Array; // message to sign (pass to WebAuthn / HSM / MPC)
};For Ed25519 signers, approvals is always empty — call client.send(result.transaction) directly.
For Secp256r1 signers, sign each approval externally, then call finalizeTransaction(result, signatures) before sending.
Replay prevention: Ed25519 signers typically omit replay prevention (Solana's native transaction dedup is sufficient). Secp256r1 signers require explicit replay prevention; use .nonce(n) for the built-in nonce or .rpp(RPP_PROGRAM_ID) for per-signer RPP isolation.
remainingAccounts: If you omit remainingAccounts, the SDK collects them automatically from your instruction list. Pass them explicitly if you need fine-grained control.
addressLookupTableAccounts (optional): Pass AddressLookupTableAccount[] to compress account keys via ALTs. Exposed in the fluent builder via .addressLookupTables(alts).
Queries
| Method | Returns | Description |
| ---------------------------------- | ---------------------- | ------------------------------------------------- |
| getSmartAccountInfo(settingsPda) | SmartAccountSettings | Fetch account state (signers, nonce, spend state) |
| deriveAddress(salt, signers) | SmartAccountPdas | Offline PDA derivation |
The SmartAccountSettings returned by getSmartAccountInfo includes signers[].role.standard.limits, which contains live currentSpent and windowStartTimestamp values.
Type Helpers
// Build signer keys
ed25519Key(publicKey); // from PublicKey or Uint8Array
secp256r1Key(uncompressedXY); // 33-byte compressed or 64-byte uncompressed passkey key
multisigKey(threshold, members); // M-of-N multisig
// Build multisig members
ed25519Member(publicKey); // from PublicKey or Uint8Array
secp256r1Member(pubkey); // compressed or uncompressed
// Build signers
adminSigner(key);
standardSigner(key); // unrestricted Standard signer (no limits)
standardSigner(key, [limit1, limit2]); // Standard signer with spending limits (outcome-based enforcement)
// Build spending limits
solLimit({ limitAmount, windowSeconds }); // native SOL limit
tokenLimit({ mint, limitAmount, windowSeconds }); // SPL token limit
// Inspect
isAdmin(role);
isStandard(role);
keyBytes(signerKey);
findSignerIndex(signers, key); // index of key in settings.signers[]
// Convert web3.js instructions for execute_transaction
fromTransactionInstruction(ix);Key types
| Type | Description |
| --------------- | -------------------------------------------------------- |
| BuildResult | { transaction, approvals } — returned by all build paths |
| Approval | { publicKey, message } — one entry per Secp256r1 signer |
| SignatureEntry| { publicKey, signature } — passed to finalizeTransaction |
Error Handling
The SDK provides typed errors with actionable messages:
import { withParsedErrors } from "@solana-smart-account/sdk";
try {
await withParsedErrors(async () => {
const result = await client.executeTransaction({ ... });
await client.send(result.transaction);
});
} catch (e) {
if (e instanceof SmartAccountError) {
console.log(e.code); // "SpendingLimitExceeded"
console.log(e.message); // includes fix suggestion
}
}Or parse errors manually with parseSmartAccountError(err).
The SDK also validates transaction size before sending. If a transaction exceeds the Solana limit (1232 bytes), a SmartAccountError with code "TransactionTooLarge" is thrown before any RPC call is made. Use assertTransactionSize(serializedBytes) for custom validation.
Settings Compression
Dormant smart accounts can compress their Settings PDA into a 33-byte hash receipt, reclaiming rent. The Smart Account PDA and its funds are unaffected. Restoration is permissionless — anyone who has the original settings data can restore it.
Compress (admin sets authority first)
// Step 1 — designate a compress authority (Admin-only, one-time per wallet)
await client.setCompressAuthorityAndSend({ settingsPda, compressAuthority });
// Step 2 — compress (called by the compress authority)
await client.compressSettingsAndSend({ settingsPda, configHash });Restore — inline path (small wallets)
Pass the raw settings bytes directly. Works as long as the serialized settings data fits within the Solana transaction size limit.
await client.restoreSettingsAndSend({
settingsPda,
configHash,
accountBump,
settingsData,
});Restore — buffer path (large wallets)
For wallets whose settings data exceeds the available transaction space, write the data into a temporary buffer PDA first, then call restoreSettings pointing at the buffer.
// Step 1 — write settings data into a buffer PDA (chunked if needed)
await client.writeRestoreBufferAndSend({
configHash,
settingsData,
bufferId: 0,
});
// Step 2 — restore from buffer (pass empty settingsData + buffer PDA as remaining account)
await client.restoreSettingsAndSend({
settingsPda,
configHash,
accountBump,
settingsData: Buffer.alloc(0), // empty signals "read from buffer"
remainingAccounts: [{ pubkey: bufferPda, isSigner: false, isWritable: true }],
});The bufferId (u8) is caller-chosen. If you wrote incorrect data into a buffer, pass a fresh bufferId to start over — the old buffer can be cleaned up separately.
Clean up a stale buffer
close_restore_buffer is permissionless — anyone can reclaim rent from an abandoned buffer.
await client.closeRestoreBufferAndSend({ configHash, bufferId: 0 });Security Notes
- Admin vs Standard: Admin signers can add/remove signers and update spending limit policies. Standard signers can only execute transactions, subject to their policies.
- Spending limit enforcement is on-chain and unforgeable: The program snapshots balances before any CPI, so even a malicious instruction that briefly inflates a balance cannot bypass the check. All enforcement runs on-chain — SDK-side logic is for convenience only (ATA resolution, nonce fetching).
- Replay prevention: The
replay_preventionargument onexecute_transactionselects between the built-in nonce and an external RPP. Ed25519 signers typically passnull(Solana's native transaction dedup is sufficient). Secp256r1 signers need explicit replay prevention. When using RPP, the wallet's configuredrppProgramIdis enforced on-chain. - CPI reentrancy: The program rejects any CPI that targets itself or the configured RPP, preventing mid-execution state manipulation.
- Settings protection: During CPI execution, the settings account is forced read-only regardless of what the caller specifies.
Replay Prevention (RPP)
The program uses a built-in non-decreasing nonce by default. The submitted nonce must be >= current on-chain value; after success the stored nonce becomes submitted + 1. Callers can skip ahead (e.g., 0 → 100), so a dropped transaction never blocks future ones. For Secp256r1 signers with concurrent transactions, you can use an external Replay Prevention Program for per-signer nonce isolation. The wallet stores the chosen rppProgramId in Settings at creation time or via setRppProgramAndSend, and transactions using .rpp() pass only the RPP params plus the required CPI accounts.
RppClient
The SDK ships a separate RppClient for managing the RPP program itself — authorizing wallets, managing the treasury, and querying state. It is separate from SmartAccountClient because the RPP has its own authority model.
import { RppClient, RPP_PROGRAM_ID } from "@solana-smart-account/sdk";
const rppClient = new RppClient({ provider });
// Authorize a wallet (authority-only)
await rppClient.authorizeWalletAndSend({ wallet: smartAccountPda });
// Check whether a wallet is currently authorized
const state = await rppClient.getWalletRppState(smartAccountPda);
console.log(state?.authorized); // true
// Deauthorize a wallet (permanent in V1 — no re-auth path)
await rppClient.deauthorizeWalletAndSend({ wallet: smartAccountPda });
// Fund the shared treasury (permissionless)
await rppClient.fundTreasuryAndSend({ amount: 10_000_000 }); // 0.01 SOL
// Withdraw from treasury (authority-only)
await rppClient.withdrawTreasuryAndSend({
amount: 5_000_000,
recipient: myPubkey,
});
// Rotate the RPP authority
await rppClient.updateAuthorityAndSend({ newAuthority: newAuthorityPubkey });Setup (deployer only)
// Called once after deployment — creates RppConfig and the treasury PDA
await rppClient.createRppAndSend({ depositLamports: 100_000_000 }); // 0.1 SOLBuilder pattern
Every operation has a builder method returning a TransactionInstruction and a *AndSend convenience wrapper:
const ix = rppClient.authorizeWallet({ wallet: smartAccountPda });
const tx = new Transaction().add(ix);
await provider.sendAndConfirm(tx, [authorityKeypair]);Queries
| Method | Returns | Description |
| --------------------------- | ------------------- | -------------------------------------------------------- |
| getRppConfig() | RppConfig \| null | Global config: authority, treasury PDA, treasury balance |
| getWalletRppState(wallet) | RppState \| null | Per-wallet state: authorized flag, active nonce entries |
| getTreasuryBalance() | number | Current treasury lamport balance |
getRppConfig() returns null if create_rpp has not been called yet. getWalletRppState() returns null if the wallet has never been authorized.
Maintenance
// Permissionless — prunes expired entries, returns freed rent to the treasury
await rppClient.pruneStaleEntriesAndSend({ wallet: smartAccountPda });See the RPP program docs for the full authority model, trust assumptions, and V1 limitations.
Signer Types
| Type | Key Size | Auth Mechanism | | ---------------- | -------- | ---------------------------------------------------- | | Ed25519 | 32 bytes | Standard Solana transaction signing | | Secp256r1 (P256) | 33 bytes | Precompile introspection (passkeys, WebAuthn) | | Multisig | Variable | M-of-N: Ed25519 + Secp256r1 member proofs in one tx |
Secp256r1 signers use the Secp256r1SigVerify precompile. The precompile instruction must be included in the same transaction — the program verifies it via instruction sysvar introspection. The SDK handles this automatically via finalizeTransaction.
Multisig signers embed a MultisigKey { threshold, members } directly in the signer slot. All member proofs (Ed25519 tx signatures + Secp256r1 precompile instructions) must appear in the same transaction. There is no on-chain proposal state — this mirrors the Stellar smart account multisig model.
