@arcurrent/mandate-sdk
v0.1.0
Published
Typed client for MandateEscrow, the open, permissionless escrow and reputation primitive live on Arc mainnet.
Maintainers
Readme
@arcurrent/mandate-sdk
A typed client for MandateEscrow, the open, permissionless escrow and reputation contract live on Arc mainnet at 0xca901f58fb82FE5FF459264a419b8cF8c75b3371 (source verified).
Anyone can fund a mandate for anyone (or leave it open), a fulfiller posts a hash of their proof, and the funder releases the USDC atomically, optionally split across several addresses in one transaction. Every outcome updates an on-chain reputation ledger per address. There is no owner and no fee.
You do not need to be an "Arc project" to use it: any script or agent can call it as long as the transaction is sent to Arc mainnet (a wallet with a little USDC for gas).
Status: lives in this monorepo and is not published to npm yet. Until it is, build it from the repo (
npm install && npm run build -w @arcurrent/mandate-sdk) and depend on the workspace.
Quickstart
import { MandateClient } from "@arcurrent/mandate-sdk";
// Read-only: no wallet, no key.
const reader = new MandateClient();
const mandate = await reader.getMandate(4); // null if the id does not exist
const rep = await reader.reputationOf("0xFde19f3BCd5482544ce672391d839a56dA96BFEE");
// Read-write: use a dedicated, low-balance wallet for anything autonomous.
const client = MandateClient.fromPrivateKey(process.env.PRIVATE_KEY as `0x${string}`, {
maxAmountUsdc: "25", // hard ceiling per createMandate
});
// 1. Lock USDC for a fulfiller (omit `fulfiller` to leave it open, omit `deadline` for no refund path).
const { mandateId } = await client.createMandate({
amountUsdc: "10",
fulfiller: "0xFulfillerAddress",
deadline: { days: 14 },
});
// 2. The fulfiller posts proof (as text, which is hashed, or a ready-made 32-byte hash).
await fulfillerClient.submitProof(mandateId, { text: "https://example.com/delivered-work" });
// 3. The funder releases: whole amount to the fulfiller, or an atomic split.
await client.release(mandateId);
await client.release(mandateId, {
splits: [
{ to: "0xFulfillerAddress", amountUsdc: "9.5" },
{ to: "0xFeeAddress", amountUsdc: "0.5" },
],
});
// Or, if nobody delivered before the deadline:
await client.refund(mandateId);API
| Method | Needs wallet | What it does |
|---|---|---|
| getMandate(id) | no | One mandate, or null. |
| listMandates({ limit, offset }) | no | Newest first. |
| reputationOf(address) | no | { completed, refunded, volumeSettled } from the contract's ledger. |
| verifyProof(id, evidenceText) | no | Does this text hash to the proof committed on-chain? |
| createMandate({ amountUsdc, fulfiller?, deadline? }) | yes | Approves the exact amount if needed, then locks it. Returns the new id, read from this transaction's own event. |
| submitProof(id, { text } \| { hash }) | yes | As the designated fulfiller, or first caller on an open mandate. |
| release(id, { splits? }) | yes | Funder only. Splits must sum exactly to the locked amount. |
| refund(id) | yes | After the deadline, if no proof was posted. |
Helpers: parseUsdc, formatUsdc, hashProof, resolveSplits, plus the raw mandateEscrowAbi, ARC_MAINNET and arcMainnetChain for wiring into other viem code.
AgentVault: pay from a treasury that bounds you
VaultClient talks to an AgentVault, a contract that holds a treasury and lets an
agent (the operator) pay only inside rules a human (the owner) set on-chain: a
per-payment cap, a daily cap that refills continuously, an optional payee allowlist, and a
pause switch. The operator can never withdraw or change a rule, so leaked agent
credentials are bounded by numbers the owner chose, not by the balance.
import { VaultClient } from "@arcurrent/mandate-sdk";
// The agent: an operator key that holds only gas.
const agent = VaultClient.fromPrivateKey(process.env.OPERATOR_KEY as `0x${string}`, {
vaultAddress: "0xYourVault",
});
const policy = await agent.getPolicy(); // balance, caps, allowance available right now, paused, roles
// Ask first: never throws, spends no gas, and says exactly why a payment would be refused.
const check = await agent.checkPay({ to: "0xVendor", amountUsdc: "0.5" });
if (!check.ok) console.log(check.code, check.message); // e.g. OVER_DAILY_ALLOWANCE: refills in ~40 minutes
// One atomic transaction: creates and releases a MandateEscrow mandate.
const { mandateId, explorerUrl } = await agent.pay({ to: "0xVendor", amountUsdc: "0.5", ref: "invoice-42" });
// The owner (a different wallet): rules, funds, emergency stop.
const owner = VaultClient.fromPrivateKey(process.env.OWNER_KEY as `0x${string}`, { vaultAddress: "0xYourVault" });
await owner.setLimits({ perPaymentUsdc: "2", dailyUsdc: "10" });
await owner.setPayee("0xVendor", true);
await owner.pause(); // the owner or the guardian; only the owner resumes
await owner.withdraw("0xOwner", "5"); // works even while paused| Method | Who | What it does |
|---|---|---|
| getPolicy(), isPayeeAllowed(), getWiring() | anyone | Live rules, balance and the escrow and token the vault is wired to. |
| checkPay({ to, amountUsdc }) | anyone | Would this payment go through right now, and if not, why. |
| pay({ to, amountUsdc, ref? }) | operator | Pays through the vault; refused up front with a specific code if any rule would reject it. |
| setLimits, setAllowlistRequired, setPayee(s), setOperator, setGuardian, unpause, withdraw, transferOwnership | owner | Change rules, rotate wallets, take funds out. |
| pause() | owner or guardian | Stop all payments. |
| acceptOwnership() | pending owner | Second step of a handover. |
Extra error codes for the vault: PAUSED, OVER_PER_PAYMENT_CAP, OVER_DAILY_ALLOWANCE,
PAYEE_NOT_ALLOWED, INVALID_PAYEE. Pass chain: arcTestnetChain (and your own escrow and
vault addresses) to work against Arc testnet.
Built for callers that cannot afford surprises
- Spend cap.
maxAmountUsdcrejects any singlecreateMandateabove it before any read or transaction. - Exact-amount approvals. The escrow is approved for exactly what a mandate needs, not unlimited, unless you pass
approveMax: true. - Simulate first. Every write is simulated, so a call the contract would reject (wrong caller, wrong status, deadline not reached) fails with a readable error and costs no gas.
- Strict amounts.
"1e3", negative numbers, and more than 6 decimals are rejected, never rounded. - Race-proof ids. The contract is permissionless, so guessing the id from
nextMandateId()could return someone else's mandate. The id comes from theMandateCreatedevent in your own receipt, matched on your address. - Stable error codes. Failures are
MandateErrorwith acode(AMOUNT_OVER_CAP,NOT_AUTHORIZED,WRONG_STATUS,DEADLINE_NOT_REACHED,INSUFFICIENT_BALANCE,CONTRACT_REVERT, ...) you can branch on. - Receipt polling that works on Arc. Arc's public RPC can serve a block-filter poll from a backend that has not indexed a fresh transaction yet, so
waitForTransactionReceiptcan hang on a transaction that already confirmed. The SDK pollsgetTransactionReceiptdirectly against one endpoint instead.
Trust model, stated plainly
The funder's release is the only condition. There is no oracle judging the work and no arbitration: a funder can withhold release after a fulfiller has posted proof, and the contract offers no recourse for that. Reputation records outcomes, it does not prevent bad behaviour. The contract is self-reviewed (static analysis plus unit tests) but not professionally audited; keep amounts proportionate.
