| name | senddy |
| description | Create and manage private stablecoin wallets using Senddy's zero-knowledge protocol on Base. Use when building payment agents, bots, server-side apps, or any system that needs private USDC transfers. Covers @senddy/node for headless agents and @senddy/client for browser apps. |
| metadata | {"openclaw":{"requires":{"env":"[Truncated]"},"primaryEnv":"SENDDY_API_KEY","emoji":"🛡️","homepage":"https://senddy.com"}} |
Senddy Private Wallet
Build private stablecoin wallets with zero-knowledge proofs on Base.
Senddy lets agents and apps hold, transfer, and withdraw USDC privately —
no public on-chain linkage between deposits, transfers, and withdrawals.
Quick Start (Headless Agent)
5 steps to a working private wallet:
npm install @senddy/node
import { createSenddyAgent, toUSDC } from '@senddy/node';
import { randomBytes } from 'node:crypto';
const seed = randomBytes(32);
const agent = createSenddyAgent({
seed,
apiKey: process.env.SENDDY_API_KEY!,
});
await agent.init();
console.log('Address:', agent.getReceiveAddress());
const balance = await agent.getBalance();
await agent.transfer('senddy1...recipient', toUSDC('5.00'));
await agent.withdraw('0xPublicAddress...', toUSDC('10.00'));
Set SENDDY_API_KEY in your environment. Get one at https://senddy.com.
Configuration
Minimal Config (recommended)
Only seed and apiKey are required. Everything else defaults to the
canonical Base mainnet deployment:
createSenddyAgent({
seed: Uint8Array,
apiKey: string,
})
Full Config (overrides)
createSenddyAgent({
seed: Uint8Array,
apiKey: string,
apiUrl: string,
chainId: number,
rpcUrl: string,
pool: '0x...',
usdc: '0x...',
permit2: '0x...',
subgraphUrl: string,
attestorUrl: string,
relayerUrl: string,
context: string,
debug: boolean,
})
What the API Key Gates
The apiKey authenticates all requests through the Senddy API gateway:
- Attestor — ZK proof verification (TEE-based, off-chain)
- Relayer — Gas-sponsored transaction submission (you don't pay gas)
- Usernames — Resolve
senddy1... addresses to human-readable names
- Merkle tree — Proof generation helper endpoints
Operations
Balance
const balance = await agent.getBalance();
estimatedUSDC is in 6-decimal USDC units. shares are 18-decimal internal units.
Transfer
const result = await agent.transfer('senddy1...', toUSDC('25.00'));
await agent.transfer('senddy1...', toUSDC('5.00'), { memo: 'Payment' });
await agent.transfer('senddy1...', toUSDC('5.00'), { anonymous: true });
Auto-escalation: tries spend circuit (3 inputs), escalates to spend9
(9 inputs), and auto-consolidates if neither suffices.
Withdraw
Withdraw to a public Ethereum address (USDC leaves the privacy pool):
const result = await agent.withdraw('0x...', toUSDC('50.00'));
Sync
State is synced automatically on init(). For long-running agents:
const result = await agent.sync();
Consolidation
When notes fragment (many small UTXOs), consolidate them:
const result = await agent.consolidate({ noteThreshold: 16 });
Receive Address
const address = agent.getReceiveAddress();
Share this address to receive private transfers. It's derived from
your viewing public key and is deterministic for a given seed + context.
Transaction History
const txs = await agent.getTransactions({ limit: 50 });
Events
agent.on('balanceChange', (balance) => { });
agent.on('sync', (result) => { });
agent.on('noteStrategy', (event) => { });
agent.on('error', (err) => { });
Multiple Agents from One Seed
Use the context parameter to derive different wallets from the same seed:
const treasury = createSenddyAgent({ seed, apiKey, context: 'treasury' });
const payroll = createSenddyAgent({ seed, apiKey, context: 'payroll' });
const tips = createSenddyAgent({ seed, apiKey, context: 'tips' });
Each context produces different keys and a different receive address.
Amounts
Always use toUSDC() to convert human-readable amounts:
import { toUSDC } from '@senddy/node';
toUSDC('1.00')
toUSDC('100')
toUSDC('0.01')
toUSDC(50)
Raw amounts are in USDC's 6-decimal format (bigint).
Address Validation
import { isValidSenddyAddress } from '@senddy/node';
isValidSenddyAddress('senddy1qw508d6q...');
isValidSenddyAddress('0x...');
Contract Addresses
import { SHARED_CONTRACTS, V3_CONTRACTS } from '@senddy/node';
SHARED_CONTRACTS.USDC
SHARED_CONTRACTS.Permit2
V3_CONTRACTS.Pool
Cleanup
agent.destroy();
Always call destroy() when done (especially in short-lived processes).
CRITICAL: Run as a Persistent Process
Do NOT create a new agent and call init() on every request. The init()
call takes 5-15 seconds because it compiles WASM, loads the SRS into memory,
and syncs the full state from the subgraph. Re-initializing on every request
will make the agent unusably slow.
Instead, run the agent as a long-lived background process that initializes
once and handles requests over a local HTTP API or Unix socket:
import { createSenddyAgent, toUSDC, isValidSenddyAddress } from '@senddy/node';
import { createServer } from 'node:http';
const agent = createSenddyAgent({
seed: Buffer.from(process.env.AGENT_SEED_HEX!, 'hex'),
apiKey: process.env.SENDDY_API_KEY!,
});
await agent.init();
console.log(`Agent ready: ${agent.getReceiveAddress()}`);
setInterval(() => agent.sync().catch(console.error), 30_000);
const server = createServer(async (req, res) => {
if (req.method !== 'POST') { res.writeHead(405); res.end(); return; }
const chunks: Buffer[] = [];
for await (const chunk req) chunks.(chunk );
{ method, params } = .(.(chunks).());
{
: ;
(method) {
:
result = agent.();
result = { ...result, : result..(), : result..() };
;
:
result = { : agent.() };
;
:
result = agent.(params., (params.), params.);
result = { ...result, : result..() };
;
:
result = agent.(params., (params.));
result = { ...result, : result..() };
;
:
result = agent.();
;
:
result = agent.(params);
;
:
res.();
res.(.({ : }));
;
}
res.(, { : });
res.(.({ : , result }));
} (: ) {
res.(, { : });
res.(.({ : , : err. }));
}
});
= (process.. ?? );
server.(, , {
addr = server.() ().;
.();
});
Start the daemon once. It picks a free port automatically (or set
SENDDY_DAEMON_PORT to pin one). Read the port from stdout and use it
for all subsequent requests:
AGENT_SEED_HEX="your64charhex..." SENDDY_API_KEY="sk_live_..." \
npx tsx senddy-daemon.ts
Then query it instantly from any client:
curl -s -X POST http://127.0.0.1:18790 \
-d '{"method":"getBalance"}' | jq
curl -s -X POST http://127.0.0.1:18790 \
-d '{"method":"transfer","params":{"to":"senddy1...","amount":"5.00"}}' | jq
For a complete daemon example with process management, see examples.md.
Gotchas
- No deposits: Agents can't deposit directly. Fund them by sending a
private transfer from a funded wallet (browser app or another agent).
- In-memory storage: Notes are lost on process restart. The agent re-syncs
from the subgraph on
init(), so this is safe — just costs a few seconds.
- First init downloads SRS: The first
init() downloads a ~16 MB
structured reference string (cached to ~/.bb-crs/ for subsequent runs).
- WASM compilation: Even with cached SRS,
init() takes 5-15s to compile
the WASM prover. Always run the agent persistently, not per-request.
- Shares vs USDC: Internal values are in 18-decimal shares. Use
balance.estimatedUSDC and toUSDC() for human-readable amounts.
Additional Resources