Skip to main content
You have a testnet key and want your agent to buy a scarce on-chain capability. By the end you will have brokered a real Allocation slot to your own address, with a verifiable txHash and a minted slotTokenId. This is the how-to companion to the Quickstart: the same flow, now in your own project, shown both ways, with the failure cases a first run skips.

Before you start

  • A throwaway testnet key in CONSUMER_PK (environment only). It signs; it never broadcasts and needs no ETH.
  • pnpm install at the repository root.
  • TUSDC in that key’s address from the faucet, so it can pay for a capability.
  • A relayer to submit to: run your own with one command, or point CAPLANE_RELAYER_URL at a hosted one. New to all of this? Run the Quickstart first.
The worked example is the AllocationAdapter on Robinhood testnet (chainId 46630), the guaranteed floor with no external dependency. The generic path at the end brokers any capability.

Path A: the connect() SDK

connect() is the developer on-ramp. It loads a chain’s registry, caches the EIP-712 domain and the broker address, and brokers in one call. It holds no key: you pass your own LocalAccount, it signs, and it never broadcasts.
import { connect } from "@caplane/consumer-agent";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(process.env.CONSUMER_PK as `0x${string}`); // your key
const caplane = await connect({
  chainId: 46630,                                // Robinhood: the Allocation floor
  relayerUrl: process.env.CAPLANE_RELAYER_URL!,  // any permissionless relayer
});

const slot = await caplane.buyAllocation({
  account,                                        // your LocalAccount; signs locally, never broadcasts
  vaultId: 1n,
  feeBps: 100,                                    // the relayer's incentive
});

console.log(slot.txHash, slot.slotTokenId, slot.explorerUrl);
buyAllocation returns { txHash, orderHash, slotTokenId, explorerUrl, feeCollected }. Under the hood it runs the four steps in Path B for you: discover the live quote, build the order, sign the EIP-3009 authorization (nonce = orderHash), submit, and decode the receipt. For any other capability (gas, timeboost, attested), the generic primitive mirrors the broker exactly. You supply the adapter, the encoded params, and the amount:
const r = await caplane.broker({ account, adapter, params, amount, feeBps: 100 });
// r: { txHash, orderHash, receipt, explorerUrl, feeCollected }
connect() creates a read-only client for discovery and never a wallet client. The consumer is gas-poor: it only signs the authorization. The relayer is msg.sender. No key ever leaves your process; only the signature does.

Path B: the raw primitives

For a developer integrating into an existing client (or reimplementing in another language), the four steps connect() wraps are exported directly. Resolve everything from the registry first, exactly as the consumer CLI does:
import { loadRegistry, domainFromRegistry, signOrder } from "@caplane/shared";
import { discover, buildOrder, submit, encodeAllocationParams, decodeAllocationReceipt } from "@caplane/consumer-agent";
import { createPublicClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(process.env.CONSUMER_PK as `0x${string}`);
const registry = loadRegistry(46630);
const domain = domainFromRegistry(registry);
const broker = registry.contracts.CapabilityBroker;
const adapter = registry.contracts.AllocationAdapter;
const publicClient = createPublicClient({ transport: http(registry.rpcUrl) });
1

Discover the price

Read adapter.quote(params) on-chain. It is view, so no gas and no state change. The price and payToken it returns become the order’s amount and payToken.
const params = encodeAllocationParams(1n);          // abi.encode(uint256 vaultId)
const quote = await discover(publicClient, adapter, params); // { price, payToken }
2

Build the order

Assemble the Order from the quote and your choices. payee defaults to the consumer (self-allocation); order.nonce defaults to a random salt.
const order = buildOrder({
  consumer: account.address,
  adapter,
  params,
  payToken: quote.payToken,
  amount: quote.price,
  feeBps: 100,
  payee: account.address,
});
3

Sign the authorization (the binding)

Sign an EIP-3009 TransferWithAuthorization whose nonce is the order hash. signOrder sets nonce = orderHash = keccak256(abi.encode(order)) for you, so one signature ties the payment to the adapter, the params, the amount, the payee, and the fee.
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600);
const sig = await signOrder(account, domain, order, broker, 0n, validBefore);
// sig: { v, r, s, validAfter, validBefore, orderHash }
See the binding for why USDC itself then rejects any tampered order, and Order encoding for the byte-exact layout the hash commits to.
4

Submit to a relayer

POST the order, exactly as signed, to a relayer. Any mutation of a field breaks the orderHash, so send it verbatim. On success, decode the Allocation receipt to the minted slot id.
const relayerUrl = process.env.CAPLANE_RELAYER_URL ?? "http://localhost:8787";
const result = await submit(relayerUrl, order, sig, 46630);
const slotTokenId = decodeAllocationReceipt(result.receipt);
console.log(result.txHash, slotTokenId);
submit(relayerUrl, order, sig, chainId) returns the RelaySuccess on 200, or throws a typed SubmitError(code) you can branch on.

A real settlement

Running either path against the deployed AllocationAdapter produces the printed machine truth below. This is the settlement the Quickstart lands (Allocation slot #1 on Robinhood), shown here as the raw path prints it. Every value is real and on-chain.
discovered quote: { price: '5000000', payToken: '0x125959541Bb486058E7e3b55E49b3B04e49fBa5E' }
orderHash:        0x4c7aac9359ac3cf807e312c24fed6501aa61dfcc9c03a73c66618f6d7db9b0f9
BROKERED.
  txHash:       0xb89a566248c40431342c32281b6875e6113cd797cd82fb2d9153646abcabd845
  feeCollected: 50000
  slotTokenId:  1
The amount is 5.000000 TUSDC (6 decimals). At feeBps 100 the relayer earns 50000 (0.050000 TUSDC) and the provider receives 4.950000. Confirm it on the explorer: 0xb89a56…abd845. The Brokered event carries this orderHash, and the slot ERC721 is owned by your address.

When a relayer rejects the order

submit throws SubmitError(code) so you branch on the typed code, not on a message string. Every code below is a real RelayErrorCode from the relayer’s pipeline.
CodeHTTPWhat it meansWhat to do
FEE_BELOW_FLOOR402Your feeBps is under this relayer’s local floor.Raise feeBps, or shop a relayer with a lower floor via GET /info.
AUTH_EXPIRED422The signed window (validBefore) has passed.Re-sign with a fresh window.
WOULD_REVERT422The order would revert on-chain (sold out, insufficient balance). The relayer caught it in pre-simulation, gaslessly.Fix the inputs: fund the key, pick an open vault.
ORDERHASH_MISMATCH400Your client hash does not match the relayer’s recomputed hash.Re-encode the order. This is the encoding footgun: see Order encoding.
MALFORMED_ORDER400The request body failed validation.Check the field types (feeBps is a uint16; amount and nonce cross the wire as decimal strings).
INSUFFICIENT_RELAYER_GAS503The relayer is low on gas.Try another relayer, or run your own.
UPSTREAM_RPC502The relayer’s RPC failed.Retry, or try another relayer.
A non-JSON or unexpected response surfaces client-side as SubmitError("BAD_RESPONSE"). The full catalog is in the errors reference.

The gotchas worth stating once

Two different nonces. order.nonce is a consumer-chosen uniqueness salt that lives inside the order. The EIP-3009 nonce is the whole orderHash. They are easy to confuse; keep them distinct.
Do not hand-roll the encoding. orderHash = keccak256(abi.encode(order)) uses abi.encode, never encodePacked, and params is dynamic bytes. The SDK encodes it for you, parity-tested against the contract. If you reimplement it, match Order encoding byte for byte or every settlement reverts with ORDERHASH_MISMATCH.
No signup, no API key, no dashboard. You hold your key, you sign one order, and a permissionless relayer settles it. There is no one in the path to coordinate, censor, or fake it.