Arete documentation
Protocol design patterns
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.mdto the URL or sendingAccept: text/markdown.
For AI agents: the documentation index is at llms.txt (full corpus: llms-full.txt). A markdown source for this page is /building-protocols/patterns.md.
Common designs, each with a snippet from a complete protocol. The complete
protocols are in
evals/patterns/
of the Arete skills repository, where CI type-checks every one and runs
arete-protocol check --remote on it, so they are what the server accepts,
not only the local checker. Copy the shape, then rename steps, states and
guards for your protocol.
Every snippet uses only builtin programs (builtins(...)), so it needs no
a4 install. With an installed program the shapes are the same. Expression
syntax is in the protocol expressions reference.
An allow-list: in
Section titled “An allow-list: in”The deployment sets the list; a guard refuses anything outside it. Declare
the list as an array resource with a maxItems, and set it per deployment
([protocol.resources] allowedMints = "mints.json", a JSON array).
resources: { allowedMints: { schema: { type: "array", items: { type: "string", format: "pubkey" }, maxItems: 8 }, description: "The mints this deployment accepts.", }, }, guards: { mint_not_allowed: "args.mint in resources.allowedMints", amount_zero: "args.amount > 0",A rejection names the guard (mint_not_allowed). For a short fixed list,
args.side in ["bid", "ask"] needs no resource. Element types must match;
a string literal stands for a public key.
One of several venues: literal indexes plus in
Section titled “One of several venues: literal indexes plus in”List the venues as an array of objects in a fixed order. A step that always
uses one venue indexes it with a literal; the field after the index is
typed from the item schema. A step where the caller picks takes the venue
as an argument and checks it against the listed entries with in.
venues: { schema: { type: "array", items: { type: "object", properties: { vault: { type: "string", format: "pubkey" } }, required: ["vault"], additionalProperties: false, }, maxItems: 2, }, accounts: { fundingAccount: "wallet", recipientAccount: "resources.venues[0].vault" }, unknown_venue: "args.vault in [resources.venues[0].vault, resources.venues[1].vault]",An index computed from args (resources.venues[args.i]) is a check error:
the addresses a step can reach must be reviewable from the spec. An index at
or past maxItems is too; an index past the deployment’s actual list is
null.
Per-user accounts and PDAs
Section titled “Per-user accounts and PDAs”Each wallet that acts on an instance gets a participant record (the
participant machine), and its accounts are derived from the wallet, never
passed in: ata(wallet, mint) for token accounts, and
pda("<alias>", "<pda name>", seeds...) for a PDA the program’s IDL names
(seeds such as wallet or instance.accounts.<name>). The generated module
of an installed program lists its PDAs.
ix(splToken, "TransferChecked", { args: { amount: "args.amount", decimals: "params.decimals" }, accounts: { source: "ata(wallet, params.mint)", mint: "params.mint", destination: "ata(instance.roles.recipient, params.mint)", owner: "wallet", }, }),Create an account another wallet needs with an idempotent instruction in
the step that first needs it (or glue.atas: [{ owner, mint }]):
ix(associatedToken, "createIdempotent", { accounts: { fundingAddress: "wallet", associatedTokenAccountAddress: "ata(args.recipient, args.mint)", walletAddress: "args.recipient", tokenMintAddress: "args.mint",A participant state that is terminal closes the wallet’s participant steps;
a self-loop transition (from: ["member"], to: "member") keeps a step
repeatable.
State-only steps
Section titled “State-only steps”A step whose template has no instructions sends only the attested memo. Its transitions apply when that transaction confirms. Use it for pause, resume, close or leave, instead of a placeholder transfer:
pause: { scope: "instance", authority: { kind: "role", role: "owner" }, guards: { not_owner: "wallet == instance.roles.owner" }, transitions: [{ from: ["open"], to: "paused" }], build: [], },A create step can be state-only too (it records the instance, its roles and bound accounts). A Squads step can’t: it proposes its instructions, so it needs at least one.
A PDA the catalog IDL doesn’t name
Section titled “A PDA the catalog IDL doesn’t name”pda(...) derives only PDAs the program’s IDL names. When a catalog
program’s IDL lacks one your protocol needs (a market authority, a vault
signer), the fix is a change to that program’s catalog IDL: open an issue
or PR on AreteA4/arete-artifacts
naming the PDA, its seeds and where they come from (the program’s code or
docs). Once the program is republished, a4 install the new version and
derive it with pda(...).
Until then, take the address as an argument of the create step and bind it, so later steps read it from the instance and the creator, not every caller, supplies it:
args: { mint: "pubkey", marketAuthority: "pubkey" }, bind: { accounts: { creator: "wallet", marketAuthority: "args.marketAuthority" }, roles: { owner: "wallet" }, }, delegate: "instance.accounts.marketAuthority",Don’t hand-derive it in a handler or an SDK wrapper. When the address is the same for every instance of a deployment, a resource works too.
Dev-only conveniences in an app
Section titled “Dev-only conveniences in an app”Fund buttons, dev-wallet pickers, “skip the deadline” controls and
@usearete/automations/dev belong to the dev environment only. Gate them on
the environment the protocol client targets:
import { clientCluster } from "@usearete/automations";
// Temporary: the environment is read from the client's deployment until the// SDK exposes it as `client.environment`; then gate on// `client.environment === "dev"`.export const isDev = clientCluster(client) === "dev";{isDev && <button onClick={() => fetch("/dev/v1/airdrop", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ pubkey, sol: 5 }) })}>Fund (dev)</button>}/dev/v1 is a4 dev up’s dev API, which accepts requests from its own
origin only: let the app’s dev server proxy it (Vite’s server.proxy, with
the origin header set to the dev API’s), and never call it from a
production build.
- Gate on the client, never on
NODE_ENVor a hostname: a production build can hold a dev client by mistake, and the gate must follow the client. - On a server, the
@usearete/automations/devhelpers throw for a production client, so a build that still calls one fails at startup. Keep the production signer, store and simulator beside them and choose byisDev. - In a browser bundle, load dev-only modules with a dynamic
import()inside theisDevbranch. The bundler still emits them as a separate chunk (isDevis only known at run time), but a production client never loads or runs them; keep secrets and keys out of them regardless. The dev API itself (/dev/v1, besidea4 dev up) does not exist in production.
Dev and production builds of one app
Section titled “Dev and production builds of one app”a4 dev up regenerates the protocol client (and the protocol stack’s SDK)
for the dev environment’s deployment, under generated/.
a4 install --locked restores the published ones that arete.lock pins,
which target mainnet. Before a production build or deploy:
a4 install --locked # the published clients, exactly as arete.lock pins thema4 install --check # fails if generated/ differs from arete.lockA production build must not contain:
- a client generated for a dev environment (a host adapter with
ARETE_CLUSTER=mainnetrefuses it at startup); - dev wallet key files (
.arete/is git-ignored; keep it out of images and bundles too); - the dev API URL or an environment token;
- dev-only UI or
@usearete/automations/devreached outside anisDevbranch.
Run a4 install --locked && a4 install --check in CI before building.