Skip to content

Arete documentation

Signers and safety

For the complete documentation index optimized for AI agents, see llms.txt or llms-full.txt. A markdown version of this page is available by appending .md to the URL or sending Accept: text/markdown.

For AI agents: the documentation index is at llms.txt (full corpus: llms-full.txt). A markdown source for this page is /automations/signing.md.

Your backend signs for users, so it matters what stops it signing the wrong thing. This page covers the checks every step passes, the signers you can use, and which protections still hold if your backend is ever compromised.

definePolicy sets what may be signed for any user:

const policy = definePolicy({
deployments: [client.deploymentId], // required
clusters: ["dev", "mainnet"], // defaults to ["dev"]; mainnet only when listed
programs: [VAULT_PROGRAM_ID], // required: the programs your steps call
maxSolPerTransaction: "0.01", // required, in SOL
maxTokenAmounts: { [USDC_MINT]: "5000000000" }, // base units per mint
});

Before each signature, the step’s transactions are simulated against your RPC and checked:

  • the deployment and its cluster are allowed (dev for a client a4 dev generated, mainnet for a published one), and every program the transactions call is on the list (the compute budget, memo, system, token and associated token programs are always allowed);
  • no transaction hands control of the wallet’s funds to someone else: token approvals, authority changes, account reassignments, nonce, stake and program-upgrade authority changes are always refused, including inside other programs’ calls;
  • the SOL and tokens leaving the wallet stay under the caps. Wrapped SOL counts as SOL, and a token account closed to someone else counts too. A mint without a cap may not leave.

A transaction whose effect cannot be simulated is refused. The rules are the same as the MCP signer policy, and both are tested against one shared set of cases.

A signer may only fill the wallet’s signature. Before signing, the prepared transactions must match what the protocol committed to (each message’s hash). After signing, every message must still match, no other byte may have changed, and the wallet’s signature must verify. A signer that adds an instruction or re-encodes a transaction is refused.

import { keypairSigner } from "@usearete/automations/keypair";
const signer = keypairSigner(devWalletSecretKey);

Use it with dev wallets and tests. A local key has no provider policy behind it.

Privy server wallets let your app sign for a user who has added your app’s authorization key as a signer:

import { PrivyClient } from "@privy-io/node";
import { privyPolicyFor, privySigner } from "@usearete/automations/privy";
const signer = privySigner({
privy: new PrivyClient({ appId, appSecret }),
walletIdFor: async (address) => lookUpPrivyWalletId(address), // null without server access
authorizationPrivateKey: process.env.PRIVY_AUTHORIZATION_KEY!,
});
// The provider-side bound for one user's consent: attach it to their wallet in Privy.
const { policy: privyPolicy, unenforced } = privyPolicyFor(policy, grant);

The signer asks Privy to sign each transaction (never to send it). It prefers Transaction V1 and falls back to v0; pass transactionFormats: ['v0'] to sign only v0. When Privy’s policy denies a transaction, the step ends refused with the rule provider_policy.

privyPolicyFor builds the policy Privy enforces in its own secure environment. It allows only:

  • the programs in your policy;
  • System transfers and token TransferChecked up to the per-run caps (wrapped SOL up to the SOL cap, since it counts as SOL), checked per instruction: several capped transfers in one transaction, or one transaction after another, can add up to more, so totals remain a check in your backend;
  • closing token accounts back to the wallet;
  • signing until the consent expires.

It never allows key export.

Privy’s policies only see each transaction’s top-level instructions. So for a protocol step, where the protocol’s program moves the funds, Privy can limit which programs are called and until when, but not how much moves. unenforced lists everything it cannot express, so you can show it honestly. Steps with top-level instructions Privy cannot allow (for example wrapping SOL) are refused before Privy is asked.

Any provider (a KMS, Turnkey) can be a signer by implementing ProtocolSigner: kind (a short name such as "turnkey"), supportedTransactionFormats, canSign(wallet) and signTransactions({ wallet, intent, transactions }), which returns the signed transactions. Throw a SignerRefusal when the provider declines, so the step is refused rather than retried. Optionally implement delegationStatus(wallet) so consent can warn about missing setup.

ProtectionRuns inHolds if your backend is compromised
The protocol’s on-chain rulesSolanaYes
The wallet provider’s policyThe providerYes
The signing policyYour backendNo
The user’s consent and limitsYour backendNo
Spending windowsYour backend (your store)No
The exact-bytes checksYour backendNo

Arete never holds, stores or proxies a user’s key; it prepares transactions, records intents and relays what your backend signed. The consent signature records what the user agreed to, but it cannot stop an attacker who controls your backend. Privy’s caps do not restrict where a transfer goes, so such an attacker can repeat capped transfers to themselves until the delegation expires or is removed: the caps slow a drain, they do not bound it, and revoking a consent in your backend does not change Privy’s policy. Keep authorization keys in a secrets manager, keep delegations short, keep the program allow-list tight, and prefer protocols whose automated steps can only move funds between the user’s own positions.

The threat model in the Arete repository (typescript/automations/docs/threat-model.md) covers each threat (forged and replayed triggers, crashes after signing, consent forgery, cap races, wrong triggers) and what handles it.