Skip to content
WARDEN

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.

bash
pnpm add @warden/sdk viem

Clients#

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.

setup.ts
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#

MethodWhat 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#

ts
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#

OptionDefaultMeaning
intervalMs4000Poll interval.
timeoutMsnoneGive up with WardenTimeoutError after this long. Without it, the call waits until the request is decided or expires.
signalnoneAn AbortSignal to stop waiting.
fromBlocknoneFirst block to search for the Approved or Rejected event, to find its transaction faster.

createWardenOwner#

MethodWhat 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, acceptOwnershipAs 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()#

ts
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 you

Each 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.

ts
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.

ErrorWhenFields
WardenDeniedThe 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
WardenContractErrorAnother contract error, such as RequestNotPending, RequestExpired or NotOwner.errorName, args
WardenPolicyErrorA builder call, amount or raw config is invalid. Nothing was sent.message
WardenConfigErrorThe client lacks a wallet, an account address or a deployment for the chain.message
WardenTimeoutErrorwaitForApproval passed timeoutMs with the request still pending.message
ts
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:

ts
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 })],
  });
}