SDK reference
@warden/sdk is a typed TypeScript client for Warden accounts on Base Sepolia. The owner uses it to create an account and write policies; an agent uses it to spend under them. ESM only, with viem 2 as its one peer dependency.
Preview. @warden/sdk lives in the Warden repository under packages/ and is not on npm yet; build it from the workspace until it is. This page documents the interface in the repository. The contracts it wraps are live.
pnpm add @warden/sdk viemClients#
Both clients take ordinary viem clients. The wallet client's account is the key that signs: the owner's key for createWardenOwner, the agent's key for createWardenAgent. On Base Sepolia the factory address and deploy block are built in; on any other chain pass deployment.
import { createWardenAgent, createWardenOwner, policy } from "@warden/sdk";
const owner = createWardenOwner({ publicClient, walletClient: ownerWallet });
const { account } = await owner.createAccount();
await owner.deposit({ token: USDC, amount: "100" });
await owner.setAgent(agentAddress, policy()
.allow(USDC, { perTx: "5", perDay: "50" })
.allowRecipients([apiProvider])
.approvalAbove(USDC, "20")
.expires("2026-12-31"));
const agent = createWardenAgent({ publicClient, walletClient: agentWallet, account });
const result = await agent.pay({ token: "USDC", to: apiProvider, amount: "3", memo: "inv-1042" });Amounts and tokens#
Strings and numbers are whole token units: "2.5" means 2.5 USDC. The SDK reads decimals() from the token and refuses amounts with more decimal places than the token has. A bigint is taken as base units (2_500_000n).
token accepts an address, "ETH", or a symbol the deployment knows. On Base Sepolia "USDC" is Circle's test USDC.
createWardenAgent#
| Method | What it does |
|---|---|
check({ token, to, amount }) or check({ target, data, value }) | Calls the contract's free check view. Returns { decision: "allowed" }, { decision: "needs_approval" } or { decision: "denied", reason, message }. |
pay({ token, to, amount, memo? }) | Runs check, throws WardenDenied if refused, otherwise sends pay. Returns executed with the receipts, or pending_approval with the request id. |
call({ target, data?, value?, memo? }) | The same flow for a contract call. ERC-20 calldata counts against the token's limit. |
waitForApproval(requestId, options?) | Polls until the owner approves or rejects, or the request expires. |
requestStatus(requestId) | The request's current state, without waiting. |
balance(token) | What the account holds of token, plus this agent's caps and what is left in the current window. |
budget() | The agent's whole policy and the budget per token. |
pay results#
const result = await agent.pay({ token: "USDC", to: apiProvider, amount: "3", memo: "inv-1042" });
if (result.status === "executed") {
console.log(result.txHash, result.receipts); // Spent receipts with the policy hash
} else {
// status "pending_approval": nothing moved; the owner decides before result.expiresAt
const outcome = await agent.waitForApproval(result.requestId, { timeoutMs: 15 * 60_000 });
// { status: "approved", txHash } | { status: "rejected", reason } | { status: "expired", expiresAt }
}memo takes up to 32 bytes of text, or a 0x bytes32. Invoice numbers work well. Pass preflight: false to skip the check call; a refusal is still decoded from the simulation before anything is sent.
waitForApproval options#
| Option | Default | Meaning |
|---|---|---|
intervalMs | 4000 | Poll interval. |
timeoutMs | none | Give up with WardenTimeoutError after this long. Without it, the call waits until the request is decided or expires. |
signal | none | An AbortSignal to stop waiting. |
fromBlock | none | First block to search for the Approved or Rejected event, to find its transaction faster. |
createWardenOwner#
| Method | What it does |
|---|---|
predictAddress({ salt? }) | The account address for the signer and salt, before it exists. |
createAccount({ salt? }) | Deploys through the factory, or returns the existing account, and binds the client to it. Default salt "main", the same one the dashboard uses. |
setAgent(agent, policy) | Registers or replaces an agent. Takes a policy() builder or a raw AgentConfig. Returns the policy hash. |
revokeAgent(agent) | Removes the agent. Its spending this window still counts if you add it back. |
approve(id), reject(id, reason?) | Decides a pending request. approve re-checks the policy without the threshold. |
setPaused(bool) | Freezes every agent, approvals included. Withdrawals still work. |
deposit({ token, amount }) | Sends ETH, or approves and calls depositToken for an ERC-20. |
withdraw({ token, amount, to? }) | Pays out to to, by default the owner. amount: "all" empties the token. |
setRequestTtl, transferOwnership, acceptOwnership | As in the contract. |
requests({ status?, agent? }) | Approval requests from events, newest first: pending, approved, rejected or expired. |
activity({ agent?, fromBlock?, toBlock? }) | Spent and Called receipts with block, transaction, memo, timestamp and policy hash, newest first. |
agents({ includeRevoked? }) | Every agent with its policy and, per token, what was spent and what remains in the current window, when it resets, and the approval threshold. |
Events are read with eth_getLogs from the factory's deploy block. The public Base Sepolia RPC answers at most 200 blocks per request; the SDK detects the limit and shrinks its chunks. Over months that is many requests, so pass an RPC with a wider range or set logChunkSize.
policy()#
policy()
.allow(USDC, { perTx: "5", perDay: "50" }) // or { perTx, perPeriod: "200", period: "7d" }
.allow("ETH", { perTx: "0.01" }) // no window cap, only per payment
.allowRecipients([apiProvider, dataVendor])
.allowCall(router, ["swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"])
.approvalAbove(USDC, "20") // 20 and above waits for the owner
.validFrom("2026-11-01") // start of that day, UTC
.expires("2026-12-31") // end of that day, UTC
.build({ decimals }); // setAgent calls build() for youEach method checks its own arguments, and build() checks the combination. A mistake throws WardenPolicyError naming it: a token allowed twice, perDay mixed with perPeriod, a period without an amount, a zero cap, a threshold on a token with no limit, expires before validFrom, a selector that is neither a signature nor four bytes, anyRecipient() together with an allowlist, or a policy that names nobody to pay.
policyHash(config) returns keccak256(abi.encode(policy, targets, selectors, thresholds)), the value the contract stores and stamps on every receipt.
Offline evaluation#
decide(state, action) is a pure function that follows the contract's _evaluate step by step: the same order of reasons, the same calldata decoding, the threshold after the caps. applySpends records a result into the window state, and loadDecideState({ publicClient, account, agent }) reads a starting state from the chain.
const state = { now: 1_790_000_000n, agent: config, windows: {}, balances: { [USDC]: 100_000_000n } };
decide(state, { kind: "pay", token: USDC, to: apiProvider, amount: 6_000_000n });
// { decision: "denied", reason: "OVER_PER_TX", spends: [...] }Errors#
All errors extend WardenError.
| Error | When | Fields |
|---|---|---|
WardenDenied | The policy refuses a payment or call. Thrown before sending when check predicts it (source: "check"), or decoded from a Denied(bytes32) revert (source: "revert"). No transaction is sent either way. | reason, source |
WardenContractError | Another contract error, such as RequestNotPending, RequestExpired or NotOwner. | errorName, args |
WardenPolicyError | A builder call, amount or raw config is invalid. Nothing was sent. | message |
WardenConfigError | The client lacks a wallet, an account address or a deployment for the chain. | message |
WardenTimeoutError | waitForApproval passed timeoutMs with the request still pending. | message |
import { WardenDenied } from "@warden/sdk";
try {
await agent.pay({ token: "USDC", to, amount: "12" });
} catch (e) {
if (e instanceof WardenDenied && e.reason === "OVER_PER_TX") {
// Split the payment, or ask a human.
}
}The reasons are listed in the policy reference.
Without the SDK#
Everything above is a layer over the contract. With viem and the ABIs:
import { stringToHex, zeroAddress } from "viem";
const [decision, reason] = await publicClient.readContract({
address: WARDEN_ACCOUNT, abi: wardenAccountAbi, functionName: "check",
args: [agent, USDC, apiProvider, 3_000_000n, zeroAddress, "0x"],
});
if (decision === 0) {
await walletClient.writeContract({
address: WARDEN_ACCOUNT, abi: wardenAccountAbi, functionName: "pay",
args: [USDC, apiProvider, 3_000_000n, stringToHex("inv-1042", { size: 32 })],
});
}