DFNS Fast Track
Onboard one funder and one recipient, then run all four operations between them.
disburse pays the recipient, balance reads what it owns, transfer moves part of it inside the pool, send withdraws the rest. Two wallets in DFNS custody, five steps, one page.
The two parties:
- A funder — a wallet in DFNS custody that pays USDC out of its own balance into the privacy pool.
- One recipient — a Private Programmable Account that receives it, and can then spend it.
Then the four operations, in the order you will actually run them:
| Who runs it | What happens | |
|---|---|---|
disburse | the funder | 0.1 USDC leaves the funder's wallet and becomes a private note owned by the recipient |
balance | the recipient | decrypts its own notes and reports what it owns |
transfer | the recipient | moves 0.04 of it to another account inside the pool — nothing on-chain names either party |
send | the recipient | withdraws the remaining 0.06 out to its own chain address |
Every command is one line, because each account has a config file the binary reads for itself. Account Operations is the reference for every call and variable; this page is five steps, in order.
The cast
| A — the funder | B — the recipient | |
|---|---|---|
| DFNS wallet | its own | its own, a different one |
| Registered as | Funding Party (pays) and pool subject (so it can be a transfer destination) | Funding Party (that is the only self-signup route) and pool subject |
| Credentials minted | disburse | holder.read, holder.transfer, holder.send |
| Config file | ~/.ligero/funder.env | ~/.ligero/agent.env |
| Needs a GPU | no | yes, for transfer and send |
| You end up with | Effort | |
|---|---|---|
| Step 1 | Two custody wallets that can sign, both gated by an approval policy | ~1 h, once, in the DFNS dashboard |
| Step 2 | The funder registered, the disburse credential minted, funder.env written | 5 min + one approval vote |
| Step 3 | The recipient enrolled, three credentials minted, agent.env written | 5 min + one approval vote |
| Step 4 | Test USDC behind the funder, and the pool approved to spend it | 10 min + one approval vote |
| Step 5 | disburse, balance, transfer, send | — |
You need the disburse-agent binary and nothing else: no shell scripts, no jq, no curl, no Node, no package manager. Plus a real GPU on the recipient's machine for transfer and send, which prove on WebGPU with no software fallback. disburse and balance run without one.
100000 is 0.1 USDC at 6 decimals. Nothing here parses a decimal amount, deliberately: a misplaced point in a payroll figure is not an error you want discovered on-chain.
Both wallet addresses have to be on the platform's onboarding allowlist, which gates registration and credential minting. Ask whoever runs the platform; on a dev deployment it is often open to everyone. Without it, Step 2 and Step 3 stop with a 403 that mentions the allowlist by name.
Every command below is written against the dev deployment, https://dev-ligeroclear.ligetron.com, so you can copy them as they stand. On your own deployment substitute your host everywhere it appears — the site root, never the /agent-edge suffix the agent later dials. Confirm it answers before Step 2:
curl -fsS https://dev-ligeroclear.ligetron.com/api/health/db # {"status":"healthy","database":"connected"}
Step 1 — Two wallets that can sign
You need two DFNS wallets, A and B. Not one used twice: a Private Programmable Account is a (wallet, viewing key) pair, so one wallet playing both parts is one account paying itself. The binary also refuses the configuration outright — see the note on the two wallet-id variables.
In your own DFNS org — roughly an hour, most of it clicks in the dashboard, and all of it done once. Expand the block below; it is the whole of Step 1. If someone else already runs a DFNS org for Ligero, ask them for the seven values in What Step 1 hands you instead of doing any of this.
Creating the org, the identities and the two wallets
DFNS identity creation requires a human WebAuthn token: POST /auth/service-accounts answers 401 "user token is missing or invalid" whenever the caller is itself a service account, whatever permissions it holds. Wallets, roles, assignments and policies are all fine headless. So the division of labour is fixed — you create the identities, automation can do the rest.
1. Two credential keypairs. Every mutating DFNS call needs User Action Signing — a per-request challenge signed by one of these — so they cannot be curled around.
mkdir -p ~/dfns-setup && cd ~/dfns-setup
for id in provisioner agent; do
openssl genpkey -algorithm ed25519 -out dfns-$id.key
openssl pkey -in dfns-$id.key -pubout -out dfns-$id.pub
done
chmod 600 dfns-*.key
Keep this directory outside any git checkout — it is about to collect bearer tokens beside the keys.
Once its public half is in the dashboard, the private half is the only thing that can ever act as that identity. Lose it and the identity is stranded permanently; there is no reset.
2. Two service accounts. Settings → Service Accounts → New, twice, pasting the matching .pub. Each access token is shown exactly once — save them to dfns-provisioner.token and dfns-agent.token immediately. Note each one's us-… id and its base64url credential id (not the UUID; the UUID fails later, at signing time).
| Identity | Grant | Role |
|---|---|---|
| provisioner | Permissions:Create, PermissionAssignments:Create, PermissionAssignments:Read, Wallets:Create, Wallets:Read, Policies:Create, Policies:Read, Policies:Update | Bootstrap. Creates the wallets and policy, then never used again. |
| agent | Wallets:Read, Keys:Signatures:Create, Keys:Signatures:Read, Policies:Evaluations:Read | What the binary authenticates as. Requests signatures, reads the approval that gated them, can approve nothing. |
Policies:Evaluations:Read, notPolicies:Approvals:Read. DFNS renamed the permission vocabulary without renaming the API, so the SDK still callslistApprovalswhile the operation string moved. The old name reads as correct and is rejected at role creation.Keys:Signatures:Create, notWallets:GenerateSignature. The latter is no longer published, and a role naming it is rejected wholesale withRequest body failed validation.Keys:Signatures:Readis not optional. On a gated wallet the first response to a signature request is alwaysPending, so the agent must re-read it to collect the result. Without this you get a tenant that works until something actually gates it, then 403s mid-poll.
3. The approver — your own login. The approver is the identity named in the policy, and the only one that can submit a decision. On a trial that is a passkey user: you. It needs no key or token, just its us-….
Trial users are passkey-only and serviceAccountsCanApprove is gated off, so the process that decides and the identity that submits cannot be the same. Hence DFNS_APPROVER_ADVISORY=1: disburse-agent approve recomputes the digest and reports a verdict but submits nothing, and a human relays it in the portal. That is the trial's shape, not a misconfiguration — a paid plan collapses the two identities and the pause goes away.
Running that approver process needs a third service account (the evaluator, Policies:Evaluations:Read only), and DFNS_APPROVER_USER_ID must be the approver's us-…, never the evaluator's: the sweep filters listApprovals({ approverId }), so a wrong id returns an empty queue forever — no error, no log line.
4. Two wallets and one policy. Wallets → New, secp256k1 on BaseSepolia, twice — one for the funder, one for the recipient. Then one policy: activity Wallets:Sign, rule AlwaysTrigger, action RequestApproval, approvers = the user from step 3, with both wallet ids in its walletId filter.
The slot gets repointed, not added to — so the second wallet joins this policy rather than getting its own. That is fine, and it is why the binary only warns when the funder and the recipient name the same plc-…: what makes sharing safe is the policy's shape, and an AlwaysTrigger policy filtered to both wallets gates every signature from either of them. Note also that archivePolicy is a DELETE with no un-archive: archiving your only policy to run a test can strand you.
5. Optional — from a repo checkout, step 2's roles and step 4 are one script. scripts/utils/dfns-provision.mjs creates the roles, assignments, wallet and policy over the SDK and writes a ready-to-source env.sh. It is dev tooling, not part of the released binary — the dashboard route above is the supported one.
node scripts/utils/dfns-provision.mjs --gen-keys --out ~/dfns-setup # keys only, no network
# create the identities in the dashboard, then:
node scripts/utils/dfns-provision.mjs \
--base-url https://api.dfns.io \
--token-file ~/dfns-setup/dfns-provisioner.token \
--cred-id <base64url> --key ~/dfns-setup/dfns-provisioner.key \
--agent-user-id us-… --agent-cred-id <base64url> \
--approver-user-id us-… \
--pool 0x<privacy pool> --token-address 0x<ERC-20> --chain-id 84532 \
--out ~/dfns-setup
--base-url defaults to staging and a 30-day trial is on production. It is not idempotent: every id is appended to <out>/provision.json as it is created, so a half-way failure leaves a list of what to clean up rather than orphans to hunt for.
What Step 1 hands you
Seven values per wallet. Nothing to write to disk yet — Steps 2 and 3 each end with exactly one file, and onboard writes it for you. Collect these and keep the window open:
| Value | Variable it becomes | Where it comes from |
|---|---|---|
| custody provider | DISBURSE_HOLDER_SIGNER | always dfns here |
| API base URL | DISBURSE_DFNS_API_BASE_URL | https://api.dfns.io — a trial is on production, not staging |
| the wallet | DISBURSE_DFNS_HOLDER_WALLET_ID | the wa-… for this party's wallet, from step 4 above |
| its policy | DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID | the plc-… gating that wallet |
| credential id | DISBURSE_DFNS_CRED_ID | the agent service account's base64url credential id, not its UUID |
| credential key | DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE | dfns-agent.key from step 1 above |
| access token | DISBURSE_DFNS_AUTH_TOKEN_FILE | dfns-agent.token, the access token shown once at account creation |
Only the wallet id differs between the two parties (and, on a paid plan, the policy id). Everything else is the org's shared agent credential.
~/.ligero/funder.env and ~/.ligero/agent.env, each written by onboard --out in the next two steps. It refuses to overwrite either, so a re-run needs a new path. The seven values are typed once per party, into the shell, and each step's last command copies the ones that party keeps into its own file.
DISBURSE_DFNS_HOLDER_WALLET_ID is what signs the login, whichever party is registering: at that moment a wallet is proving it owns its address, and no funder role exists yet. Paying is the other variable, DISBURSE_DFNS_WALLET_ID, without HOLDER_. Both name the same wallet and the binary refuses to start when both are set, so they are kept apart on purpose: the login spelling stays in a shell you close, the paying spelling goes in the file. This is true even of a funder that never holds a balance, because the login needs it either way.
Step 2 — The funder
Wallet A registers itself, mints the one credential that can pay, and ends with one config file the agent server runs on.
mkdir -p ~/.ligero ~/dfns-setup
# 1 — wallet A's viewing key. It is PRINTED, never written: escrow it yourself.
# A funder needs one because we also enroll it as a pool subject, which is what
# makes it a valid `transfer` destination in Step 5.
(umask 077 && disburse-agent viewing-key new --json > ~/dfns-setup/funder-viewing-key.json)
# 2 — wallet A's custody, in THIS shell only. Read by the two commands below, stored nowhere.
export DISBURSE_HOLDER_SIGNER=dfns
export DISBURSE_DFNS_API_BASE_URL=https://api.dfns.io
export DISBURSE_DFNS_HOLDER_WALLET_ID=wa-… # wallet A
export DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID=plc-… # the policy covering wallet A
export DISBURSE_DFNS_CRED_ID=… # base64url, not the UUID
export DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE=$HOME/dfns-setup/dfns-agent.key
export DISBURSE_DFNS_AUTH_TOKEN_FILE=$HOME/dfns-setup/dfns-agent.token
export DISBURSE_HOLDER_VIEWING_KEY_FILE=$HOME/dfns-setup/funder-viewing-key.json
# 3 — prove the custody credential works BEFORE registering anything. One authenticated
# read from DFNS, no signature, so no approval vote. Needs no platform URL at all.
disburse-agent identity --no-env-file # signer: dfns, and wallet A's address, W and P
# 4 — register as a Funding Party, enroll as a pool subject, mint the disburse credential
disburse-agent onboard --no-env-file \
--platform https://dev-ligeroclear.ligetron.com \
--login dfns --enroll-holder \
--signer dfns \
--capabilities disburse \
--out ~/.ligero/funder.env
# 5 — the paying half of custody, which onboard cannot know. Written from what you
# exported above, so nothing is typed twice. Note the last two names.
cat >> ~/.ligero/funder.env <<EOF
DISBURSE_DFNS_API_BASE_URL=$DISBURSE_DFNS_API_BASE_URL
DISBURSE_DFNS_CRED_ID=$DISBURSE_DFNS_CRED_ID
DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE=$DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE
DISBURSE_DFNS_AUTH_TOKEN_FILE=$DISBURSE_DFNS_AUTH_TOKEN_FILE
DISBURSE_DFNS_WALLET_ID=$DISBURSE_DFNS_HOLDER_WALLET_ID
DISBURSE_DFNS_APPROVAL_POLICY_ID=$DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID
EOF
--no-env-fileon both commands. Without it they read~/.ligero/agent.envif it happens to exist, which would sign wallet A's registration with wallet B's custody. Your exports are the whole configuration here, deliberately.identityfirst, always. A wrong credential id, an expired token, a key path that does not exist, a wallet id that is not yours, a viewing-key file that does not parse: every one of them fails here instead of halfway through a registration that has already written rows on the platform. It costs one authenticated DFNS read and no approval vote, and it prints the address so you can confirm you are about to register the wallet you meant. What it cannot check is the platform side — the URL and the allowlist are stillonboard's to discover.--signer dfnswrites no funder private key at all, deliberately: under delegated custody the key never leaves the custodian, so the file getsDISBURSE_SIGNER=dfnsand the provider's own variables are the block you appended.--capabilities disbursemints exactly one key,DISBURSE_PLATFORM_TOKEN. It is the only one the funder needs; it cannot read a balance and cannot spend a note.--enroll-holderregisters wallet A's(W, P)as well, so value can be moved to it inside the pool. The pair is recorded as a# W …comment at the top offunder.env— that W is what Step 5'stransfersends to, so keep the file.- You never name the recipient here. There is no recipient list and no roster:
disburse --to <address>in Step 5 pays whoever you hand it, provided that wallet has enrolled — which wallet B does for itself in Step 3. - Leave the "Holder" section of
funder.envexactly as written. Because you enrolled,onboardputs aDISBURSE_HOLDER_SIGNER=dfnsline and an emptyDISBURSE_HOLDER_VIEWING_KEY_FILE=there. Nothing the funder runs reads either one: the agent server anddisburseuse the funder half only. Filling them in is what would recreate the two-wallet-ids-in-one-file refusal, so don't.
On this platform a wallet is an employer or an employee, permanently and irreversibly, and there is no undo. If either wallet has ever been added to a payroll roster — by POST /api/employers/add-employee, or by an older onboard --recipients — this step fails with a 409 naming an EMPLOYEE, and no flag gets past it: that wallet can never register, never mint a credential, and never run balance, transfer or send. Pick a different wallet. (The reverse 409, … already registered as employers, is the harmless one: it just means this wallet is already a Funding Party.)
The exports outrank any config file, in every process started from this shell. Left open, they are wallet A's custody quietly overriding wallet B's file in Step 5, which reads as balance refusing to start for no visible reason. One terminal per party is the whole discipline.
Done when ~/.ligero/funder.env exists, contains a DISBURSE_PLATFORM_TOKEN=lgk_live_… line, and its header records wallet A's address and W.
Step 3 — The recipient
Same shape, wallet B, its own viewing key. Open a second terminal — wallet A's exports must not be live here. This is the account that will hold and spend.
# 1 — wallet B's own viewing key. Different key, different account.
(umask 077 && disburse-agent viewing-key new --json > ~/dfns-setup/holder-viewing-key.json)
# 2 — wallet B's custody, in THIS shell only. Only the wallet id differs from Step 2.
export DISBURSE_HOLDER_SIGNER=dfns
export DISBURSE_DFNS_API_BASE_URL=https://api.dfns.io
export DISBURSE_DFNS_HOLDER_WALLET_ID=wa-… # wallet B
export DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID=plc-… # the policy covering wallet B
export DISBURSE_DFNS_CRED_ID=… # same org credential as Step 2
export DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE=$HOME/dfns-setup/dfns-agent.key
export DISBURSE_DFNS_AUTH_TOKEN_FILE=$HOME/dfns-setup/dfns-agent.token
export DISBURSE_HOLDER_VIEWING_KEY_FILE=$HOME/dfns-setup/holder-viewing-key.json
# 3 — check it before registering. Must print wallet B's address, not wallet A's.
disburse-agent identity --no-env-file
# 4 — enroll as a pool subject, mint the three holder credentials, write the config file
disburse-agent onboard --no-env-file \
--platform https://dev-ligeroclear.ligetron.com \
--login dfns --enroll-holder \
--capabilities holder.read,holder.transfer,holder.send \
--out ~/.ligero/agent.env
# 5 — the custody block onboard cannot know, copied from your exports. Here the names
# keep their HOLDER_ prefix: a recipient signs as its wallet and never pays a batch.
cat >> ~/.ligero/agent.env <<EOF
DISBURSE_DFNS_API_BASE_URL=$DISBURSE_DFNS_API_BASE_URL
DISBURSE_DFNS_HOLDER_WALLET_ID=$DISBURSE_DFNS_HOLDER_WALLET_ID
DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID=$DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID
DISBURSE_DFNS_CRED_ID=$DISBURSE_DFNS_CRED_ID
DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE=$DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE
DISBURSE_DFNS_AUTH_TOKEN_FILE=$DISBURSE_DFNS_AUTH_TOKEN_FILE
DISBURSE_HOLDER_VIEWING_KEY_FILE=$DISBURSE_HOLDER_VIEWING_KEY_FILE
DISBURSE_POOL_GATE_URL=https://api-dev.ligetron.com/pool-gate
EOF
viewing-key newwrites no file and takes no--out. It prints the key once, on purpose — the key is yours to escrow, and a tool that quietly drops a spendable secret on disk is not the default anyone wants. Redirecting--jsonis the whole job; the_FILEvariables read that document as-is. Nothing can re-create it, and every note sealed to it is unreadable without it.--login dfnssigns the login inside the custodian — no key on this host. It is itself aWallets:Sign, so it pauses for a vote: run this attended.--enroll-holderis what makes the wallet able to hold a balance rather than only fund one. It is also the only thing that makes wallet B payable — this step, run by wallet B itself, is what Step 5'sdisburse --towill resolve against. Wallet A did not, and could not, put wallet B on a list.--platformis the site root, not the/agent-edgesuffix the agent later dials.- The append is not a workaround.
onboardwritesDISBURSE_HOLDER_SIGNER=dfnsand an emptyDISBURSE_HOLDER_VIEWING_KEY_FILE=, because it cannot know your credential paths. An empty value in a config file counts as absent, so the appended lines win with no editing — and this file, unlike the funder's, keeps every name exactly as you exported it. DISBURSE_POOL_GATE_URLis on the last line for a reason:transferandsenddo not start without it. They are one-shot roles that prove in-process, and a spend is checked against the whitelist and the blacklist over the spender's ownW— so the recipient needs eligibility witnesses of its own, read directly from the pool-gate. The agent server is vended this URL at boot and therefore never needs it written down; nothing vends it to a one-shot role. Leave it out and Step 5'stransferstops withDISBURSE_POOL_GATE_URL is required for a spend. The read credential that goes with it is minted in Step 5, because it expires.
disburse-agent balance --env-file ~/.ligero/agent.env is right. disburse-agent --env-file ~/.ligero/agent.env balance is not — a leading flag means "no role named", so the binary starts the agent server instead, and the only sign of it is a complaint about a funder key you never configured. This applies to every role.
Done when both of these are true — neither needs a GPU and neither moves anything:
disburse-agent identity --env-file ~/.ligero/agent.env # signer: dfns · viewingKey: generated
disburse-agent balance --env-file ~/.ligero/agent.env # readiness: ready, and 0 notes
viewingKey: generated is the answer you wantIt means the escrowed key from your file is in use. derived means the agent invented one from the signer — it will decrypt none of the notes sealed to the P you enrolled, and report an empty balance rather than an error.
Re-running Step 2 or Step 3 for a wallet that is already enrolled
Re-running onboard against an account you set up earlier is safe and idempotent — it reports
already registered — reused the existing Funding Party account and
enrolled as a pool subject (already enrolled — the pair matched) — but only if you give it that
account's original viewing key.
Point DISBURSE_HOLDER_VIEWING_KEY_FILE at the key you escrowed the first time. Generating a fresh
one and pairing it with an already-enrolled wallet makes --enroll-holder re-register that wallet's
P, and every note already sealed to the old key becomes permanently unreadable. Nothing warns you:
the run succeeds and the balance quietly reads zero.
--out is never overwritten, so a re-run needs a new path (or the old file moved aside). And a new
account is the other case — it needs its own new wallet and its own new key, and the wallet must
be added to the approval policy's walletId filter before it is gated.
Step 4 — Put USDC behind the funder
disburse moves real ERC-20 out of wallet A, so wallet A needs two things on chain that only you can put there: the tokens, and enough native currency to sign one transaction of its own. Both are checked before any proving work happens, which is why this is its own step rather than a surprise in Step 5.
The values below are Base Sepolia. For another chain, take poolAddress and the token address from Getting Started → 2c.
4a — A balance
Send test USDC to wallet A's address, from a faucet or any wallet you control. Wallet A's address is in the header comments of ~/.ligero/funder.env.
4b — Native gas
The pool pulls the USDC out of wallet A rather than being sent it, so wallet A has to grant it permission first — one ERC-20 approve call. The agent does that for you (see below), but wallet A pays for that transaction itself, so it also needs a little native currency. On Base Sepolia, a few thousandths of an ETH is plenty.
This is the one thing that catches people out, because a disbursement itself costs wallet A no gas — the relayer broadcasts that one. The allowance is the exception: it is wallet A's own transaction.
Check both before moving on
So you find out here rather than three commands into Step 5. Read-only, and needs nothing installed:
FUNDER=0x… # wallet A, from the funder.env header
RPC=https://sepolia.base.org
USDC=0x036CbD53842c5426634e7929541eC2318f3dCF7e
POOL=0xF9778A61Faf1F793b50996168CC5F31d19DA6972
pad() { printf '000000000000000000000000%s' "$(echo "${1#0x}" | tr 'A-F' 'a-f')"; }
call() { curl -s "$RPC" -H 'content-type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"to\":\"$USDC\",\"data\":\"$1\"},\"latest\"]}" \
| sed 's/.*"result":"\(0x[0-9a-f]*\)".*/\1/'; }
echo "balance $(( $(call 0x70a08231$(pad $FUNDER)) ))"
echo "allowance $(( $(call 0xdd62ed3e$(pad $FUNDER)$(pad $POOL)) ))"
Both numbers are base units, so 2500000 is 2.5 USDC. Done when the balance covers what you intend to disburse in Step 5 and wallet A holds some native currency. The allowance may read 0 — that is expected, and Step 5 fixes it.
The allowance, which the agent grants itself
When disburse finds the allowance short, it grants exactly the amount that batch needs and carries on. Three consequences worth knowing before you see it happen:
- You will vote twice, not once. Granting an allowance is a wallet A signature like any other, so it opens its own approval request in the DFNS portal — one for the allowance, then one for the disbursement itself. The portal shows what it is approving, not a bare hash.
- Nothing is left standing.
approvesets the allowance rather than adding to it, and the batch consumes the whole grant, so wallet A is back to zero standing permission once it settles. An unlimited approval — the usual shortcut — would be a permanent licence to drain the funder; this is deliberately not that. - It never raises an allowance it finds. If you have already approved a larger amount by hand, that stands untouched and nothing is signed.
Turn it off with DISBURSE_FUND_GRANT_AUTO=0 if wallet A's allowance is managed by some other process, and grant it yourself instead: approve(spender = 0xF9778A61Faf1F793b50996168CC5F31d19DA6972, value = …) on the USDC contract, from wallet A, in the DFNS dashboard. Then Step 5 finds it already covered.
The ERC-20 approve above is the allowance — a transaction on chain. disburse-agent approve is a different thing entirely: the service that casts DFNS policy votes. Step 4 is about the first; it will make you cast the second.
If the grant cannot happen at all — no native gas, or a custody backend that only signs batches — the agent says so and falls back to the old refusal, fund_grant_insufficient: ERC-20 allowance 0 < 100000, which names precisely what to grant. It is retryable: fix the cause and resubmit under the same idempotency key.
Step 5 — The four operations
Wallet A's variables must not be exported in the shell wallet B runs in. The real environment always wins over a config file, so anything left over from sourcing funder.env silently outranks agent.env. Two ways that bites, both with messages that point away from the cause:
DISBURSE_DFNS_WALLET_IDcollides with wallet B's holder wallet id → the "holder would sign under the funder's policy" refusal.DISBURSE_HOLDER_ENCRYPTION_PUBKEY— the funder has one too, because it enrolled as a pool subject in Step 2 — makes wallet B's spend compare its own key against wallet A's declaredP→holder viewing key mismatch, naming twoPs and neither shell.
Use a fresh terminal per party and neither can arise. In a shell that is already polluted:
unset DISBURSE_HOLDER_ENCRYPTION_PUBKEY DISBURSE_HOLDER_VIEWING_KEY_FILE \
DISBURSE_SIGNER DISBURSE_DFNS_WALLET_ID DISBURSE_DFNS_APPROVAL_POLICY_ID \
DISBURSE_PLATFORM_TOKEN DISBURSE_AGENT_TOKEN
Terminal 1 — the funder. The agent server is what holds funder custody and the pool-gate credentials, so it must be running before anything can be paid:
set -a; . ~/.ligero/funder.env; set +a
disburse-agent # the agent server. Leave it running.
Terminal 2 — the funder's payment. disburse is a client: it speaks to the agent above over loopback and needs no custody of its own.
set -a; . ~/.ligero/funder.env; set +a
disburse-agent disburse --to 0x<wallet B's address> --amount 100000
It prints an idempotency key before the request goes out, then follows the job. Expect two pauses, both waiting on wallet A's policy in the DFNS portal (or on disburse-agent approve running on a separate host): first the allowance from 4b, if it is not already covered, then the funding transaction itself. On a second run against the same funder the first pause is usually still there — the previous batch consumed the previous grant.
insufficient on a run that completeddone — status completed
allowance: insufficient at the pre-proof check — the agent granted what this batch needed before proving
tx 0x885c…0ce41
Not a contradiction, and nothing to fix. allowance is the reading from before proving started, reported exactly as observed and never recomputed afterwards — because a batch that failed because the grant was missing used to report it present, which cost two full proofs to diagnose. In order: the read said insufficient → the agent granted precisely this batch's amount, waiting on the first vote → it proved → fund() confirmed, the second vote. The tx hash and status: completed are the settlement.
The reading matters when a run fails: fund_grant_insufficient means the grant could not be made — see the failures table below.
Terminal 3 — the recipient. All three read ~/.ligero/agent.env, so --env-file is only needed if it is not your default path. balance runs as it stands; the two spends need one more credential first, minted just below.
disburse-agent balance # 100000 USDC, 1 note
The spend read credential, minted here because it expires
transfer and send read the pool-gate directly to prove the spender's own eligibility, so they need a pool:read bearer to go with the DISBURSE_POOL_GATE_URL already in agent.env. The platform vends one to any holder of the funder's disburse key.
All of this runs in terminal 3, the recipient's. The funder's key is read out of funder.env by path, with sed — the file is never sourced, so nothing of wallet A's leaks into the shell that is about to spend, and there is nothing to paste between terminals:
# Terminal 3 — the recipient's. Reads funder.env; does NOT source it.
TOKEN=$(sed -n 's/^DISBURSE_PLATFORM_TOKEN=//p' ~/.ligero/funder.env)
BOOT=$(curl -fsS https://dev-ligeroclear.ligetron.com/agent-edge/bootstrap \
-H "authorization: Bearer $TOKEN") || echo "FAILED — see the 401 note below"
export DISBURSE_POOL_GATE_TOKEN=$(echo "$BOOT" | sed 's/.*"readToken":"\([^"]*\)".*/\1/')
echo "# good for $(echo "$BOOT" | sed 's/.*"readTokenExpiresInSec":\([0-9]*\).*/\1/') seconds"
That is the only variable to set. The matching DISBURSE_POOL_GATE_URL went into agent.env in Step 3 and is read from there; the baseUrl in the bootstrap response is the same value, and exporting it again changes nothing.
curl: (22) … error: 401, then # good for seconds with the number missingThe bearer was empty or is not a disburse key. curl -fsS writes nothing on failure, so $BOOT stays empty and the sed extracts an empty value rather than complaining — which then exports an empty token, and an empty variable is not the same as an unset one: the spend fails later on the pool-gate instead of here. Check the key first:
sed -n 's/^DISBURSE_PLATFORM_TOKEN=//p' ~/.ligero/funder.env | cut -c1-13 # expect lgk_live_…
Empty output means you pointed at the wrong file — a holder capability cannot mint a pool-gate token (/bootstrap is disburse-only), so agent.env will not do. It has to be the funder's funder.env.
# Same terminal 3, token now exported.
disburse-agent transfer --to <wallet A's W> --amount 40000 # inside the pool
disburse-agent balance # 60000 USDC
disburse-agent send --amount 60000 # out to wallet B's own address
It is good for under an hour (readTokenExpiresInSec, ~48 min today). A spend that fails on a pool-gate 401 after a long pause has simply outlived it: re-mint and re-run. Nothing is lost — a spend that never proved never moved anything.
Vending a pool-gate token is disburse-only on purpose: the endpoint mints a live pool-gate bearer, and letting a holder capability mint one would make every credential a route to it. What the recipient's shell touches is one line of funder.env, to obtain a short-lived read token for eligibility data — never custody, and the funder's key itself stays in the file and out of the environment. Wallet B still cannot pay a batch, and wallet A still cannot spend wallet B's notes. In a real deployment the two parties are not the same person and this step is not shared at all: see the OAuth alternative below. If you would rather nothing crossed at all, ask whoever runs the platform for the pool-gate's own POOL_GATE_OAUTH_TOKEN_URL, POOL_GATE_OAUTH_CLIENT_ID and POOL_GATE_OAUTH_CLIENT_SECRET and put those in agent.env instead — durable, self-refreshing, and the trade is that you now hold a client secret.
Wallet A's W is the 128-hex value in the # W … comment at the top of ~/.ligero/funder.env. transfer and send each need a GPU and each pause for a vote on wallet B's policy — a spend that sits for minutes is the gate doing its job, not a hang.
| Command | Capability it reads | Moves | GPU | Waits on a vote |
|---|---|---|---|---|
disburse | disburse (DISBURSE_PLATFORM_TOKEN, held by the running agent) | funder wallet → pool | no | yes, the funder's policy |
balance | holder.read | nothing | no | no |
transfer | holder.transfer | inside the pool | yes | yes, the holder's policy |
send | holder.send | pool → a chain address | yes | yes, the holder's policy |
Recipients are named by address for disburse and by public key for transfer — a note is sealed to a key, and an address cannot be inverted into one. Each refuses the other's shape by name before doing any work.
send with no --to withdraws to the holder's own wallet, which is all that is possible until DISBURSE_SEND_DESTINATIONS names some labels. To operate as a different account: --env-file <path>, or export LIGERO_ENV=<path>.
Under the hood
Nothing below is needed to get running. It is here for when you want to know why something behaved the way it did.
The config file — precedence, one file per identity, secrets by path
One config file per identity
Every role reads a flat KEY=value file before anything else. Three ways to name it, in order:
disburse-agent balance --env-file ~/.ligero/holder1.env # explicit, per command
export LIGERO_ENV="$HOME/.ligero/holder1.env" # once, for the shell
disburse-agent balance # else ~/.ligero/agent.env, if it exists
--env-file takes one path and is not repeatable — hence the cat … >> … closing Steps 2 and 3, rather than a second file on the command line. --no-env-file reads none, which is what Steps 2 and 3 use so that a stray default file cannot sign one wallet's registration with another wallet's custody. A file named by --env-file or LIGERO_ENV and missing is an error — you asked for it. The default path merely being absent is not.
An account is a (custody wallet, viewing key, credentials) triple. Keep one file per account rather than one file with switches: mixing two is how you prove against one enrollment and settle against another, which produces a valid proof of the wrong thing. Operating as a second account is then the whole change:
disburse-agent balance --env-file ~/.ligero/holder2.env
The binary checks the file is mode 0600 and warns if it isn't.
A variable already set when the process starts is never overwritten by the file — the opposite of most .env loaders, and deliberate. Someone writing DISBURSE_SEND_MAX_AMOUNT=1000 disburse-agent send … is stating an intent for this invocation, and a file must not silently outrank it. An empty value in the file counts as absent, not as a variable set to nothing. It is also the reason Step 5 uses one terminal per party.
~ and $HOME are expanded, but only in variables whose name ends _FILE or _DIR. Those name a path by construction, so there is no secret to corrupt; nothing else is interpolated, because ${…} inside a credential is part of the credential.
What goes in it
# Platform
DISBURSE_PLATFORM_URL=https://dev-ligeroclear.ligetron.com/agent-edge # note the /agent-edge suffix
DISBURSE_CHAIN=base_sepolia # selects the pool
DISBURSE_TOKEN_SYMBOL=USDC # the pool token; --token overrides it
# Holder custody — DFNS shown; local-key, fireblocks, enclave and local-ed25519 also valid
DISBURSE_HOLDER_SIGNER=dfns
DISBURSE_DFNS_API_BASE_URL=https://api.dfns.io
DISBURSE_DFNS_AUTH_TOKEN_FILE=...
DISBURSE_DFNS_CRED_ID=...
DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE=...
DISBURSE_DFNS_HOLDER_WALLET_ID=wa-...
DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID=plc-...
# Funder custody — the funder's file only, and never in the same file as the two lines above
DISBURSE_SIGNER=dfns
DISBURSE_DFNS_WALLET_ID=wa-...
DISBURSE_DFNS_APPROVAL_POLICY_ID=plc-...
# Secrets named by PATH, not by value
DISBURSE_HOLDER_VIEWING_KEY_FILE=.../holder-viewing-key.json # `viewing-key new` output, as-is
DISBURSE_BALANCE_TOKEN_FILE=.../key-holder.read.json # a minted credential, as-is
DISBURSE_TRANSFER_TOKEN_FILE=.../key-holder.transfer.json
DISBURSE_SEND_TOKEN_FILE=.../key-holder.send.json
# Only `disburse` needs this: the bearer for a running agent's LOCAL API
DISBURSE_AGENT_TOKEN=...
# Pool gate — omit the whole block if your deployment has no eligibility service
DISBURSE_POOL_GATE_URL=...
POOL_GATE_OAUTH_TOKEN_URL=...
POOL_GATE_OAUTH_CLIENT_ID=...
POOL_GATE_OAUTH_CLIENT_SECRET=...
DISBURSE_PROVER defaults to wasm, the shipped WASM-on-GPU prover, and DISBURSE_WASM_DIR is unnecessary: the released binary embeds the prover assets. Everything else is exactly the variables Account Operations → Configuring the agent documents, so there is no second reference to learn.
Every credential has a _FILE spelling and it wins over the inline one. Two reasons to prefer it: the config file stops being a secret you must protect at the same grade as the key, and the file may be either the bare secret or the JSON document the binary itself printed — {privateKey, publicKeys: […]} from viewing-key new --json, {apiKey, …} from a minted credential. The artifact goes from where the tool wrote it straight into the variable, with no editing step. A document with more than one field that could be the secret is refused rather than guessed at.
DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID is required, not optionalA holder in MPC custody without a gating policy is a wallet whose signature nothing votes on. The agent refuses an ungated holder signature — see A holder in DFNS custody.
GET /agent-edge/bootstrap vends the pool-gate URL and a short-lived read token to the running agent server, which is why onboard leaves the block out and the funder's file never needs it. Setting DISBURSE_POOL_GATE_URL or any POOL_GATE_OAUTH_* value suppresses that vend — explicit configuration always wins.
Nothing vends it to a one-shot role, though, so transfer and send need the URL and a pool:read bearer given to them explicitly: DISBURSE_POOL_GATE_URL in agent.env (Step 3) plus either DISBURSE_POOL_GATE_TOKEN (Step 5) or the three POOL_GATE_OAUTH_* values. balance needs none of it — it reads notes, and only a spend is checked against the lists.
Why three credentials and not one
| Command | Capability | Can it move value? |
|---|---|---|
balance | holder.read | No |
transfer | holder.transfer | Inside the pool |
send | holder.send | Out to a chain address |
onboard --capabilities mints all three; individually, Minting a scoped credential. Three keys instead of one is the point rather than the cost: someone who only wants to look should not have to hold a credential that can spend, so a dashboard gets the read key and nothing else. Sharing one across roles, or reusing the disburse key, is refused rather than quietly accepted — which is also why the funder's single disburse key cannot read its own balance.
balance — the cheapest check that a deployment is wired correctly
The only holder operation that needs no GPU: it takes no reservation and runs no prover.
It decrypts. The platform serves notes sealed to the enrolled encryption key, and the amounts are readable only by whoever holds the viewing key — so this answer cannot be obtained from the platform, and when the two disagree this is the one derived from what the notes actually say.
A 0 here is not a silently wrong viewing key. The enrolled (W, P) is verified against the platform's own record before the listing is requested, and the command reports that verification, so the case the caution in Account Operations warns about is ruled out before you see the number rather than after.
disburse — the one that talks to a running agent
The odd one out. The other three are one-shot roles that do the work in-process; a disburse is driven by a running agent's local API, because it is the agent that holds funder custody and the pool-gate credentials. So this command speaks HTTP to the agent from Step 5's first terminal and needs no signer configuration of its own — only DISBURSE_AGENT_URL (default http://127.0.0.1:8787) and a bearer. No custody, no GPU.
It prints an idempotency key before the request goes out:
idempotency-key qs-20260903T202756Z-a2c01cb3 <- rejoin with: disburse-agent disburse --key qs-20260903T202756Z-a2c01cb3 --status
20:27:56 accepted
20:28:02 proving
A key you only learn on success is no use to the caller who needs it — the one whose process died. If that happens, or your laptop sleeps, or the poll times out, the payment is still running on the agent. Rejoin rather than reissue:
disburse-agent disburse --key qs-20260903T202756Z-a2c01cb3 --status
Same key with the same body is the same payment; same key with a different body is refused (409) rather than quietly resolved. Pass --key yourself when it should come from your own system — a payroll run id, say.
A timeout is reported distinctly from a failure and says the job is not cancelled, because the opposite reading is what leads someone to pay twice. --timeout (default 600s) bounds how long the command follows the job, never the job. --to repeats to fan out.
Who can be paid: any enrolled pool subject, resolved by address. That is why Step 3 is enough to make wallet B payable, and why the roster plays no part in it.
transfer and send — the two that spend
transfer and send
Both need a real GPU and both block on a custody vote when the holder wallet is covered by a policy. A spend that sits for minutes is the gate doing its job, not a hang. --dry-run proves without submitting.
transfer takes one recipient as --to <walletPub> --amount <n>, or several as --to <walletPub>:<amount> repeated. Given both, a per-recipient amount wins over the shared --amount: the more specific statement is the one closer to the recipient, which is the direction that cannot pay someone the wrong figure by accident. The destination must be an enrolled subject — a wallet with no registered W has no key for a note to be sealed to.
send is the one operation whose result is visible on-chain, so the destination is not the caller's to choose: --to takes a label from DISBURSE_SEND_DESTINATIONS, never an address. Omit it to withdraw to the holder's own wallet; with no allowlist configured, self-withdraw is the only thing possible. That is what makes an unattended withdraw safe — see Withdraw.
Both bound the spend by the amount you asked for when DISBURSE_TRANSFER_MAX_AMOUNT / DISBURSE_SEND_MAX_AMOUNT is unset. These roles are one-shot, so "no ceiling" and "a ceiling equal to this request" permit the same spend — but the second also means a bug between the command line and note selection cannot widen it. It does not silence the warning that no ceiling is configured, and shouldn't: that warning is about the credential's whole lifetime, where a leak is bounded only by the balance. It bounds the process, not the deployment.
Failures to expect — each of these is the system working
| What you see | What it means |
|---|---|
no spendable USDC notes for this holder | The account is empty. Disburse into it first. The message confirms W and P matched, so it is not a viewing-key problem. |
fund_grant_insufficient: ERC-20 allowance 0 < 100000 | The agent tried to grant the allowance itself and could not — usually wallet A has no native currency for the transaction (4b), or the approval vote was denied or left to expire. Caught before proving rather than at settlement. Retryable: fix the cause and resubmit under the same idempotency key. |
DISBURSE_DFNS_HOLDER_WALLET_ID is the same wallet as DISBURSE_DFNS_WALLET_ID | One environment holds both arms of custody — usually a shell that sourced funder.env before running a holder command. New terminal, or unset DISBURSE_DFNS_WALLET_ID DISBURSE_DFNS_APPROVAL_POLICY_ID DISBURSE_SIGNER. |
holder viewing key mismatch on evm: the configured key produces P=…, but the declared P … is P=… | The same sourced-funder.env trap as the row above, one variable over: the funder's own DISBURSE_HOLDER_ENCRYPTION_PUBKEY is exported in the recipient's shell, and the real environment outranks agent.env, so the recipient's key is compared against the funder's declared P. The first P in the message is the right one. New terminal, or unset DISBURSE_HOLDER_ENCRYPTION_PUBKEY. Not a corrupted key — nothing was enrolled wrong. |
DISBURSE_POOL_GATE_URL is required for a spend | transfer or send with no pool-gate configured. Both prove the spender's own eligibility against the lists, and only the agent server gets that URL vended — a one-shot role never does. Add the URL to agent.env (Step 3) and the read token (Step 5). balance is unaffected, which is why it can pass while both spends fail. |
A pool-gate 401 on a spend that paused for a while | The vended DISBURSE_POOL_GATE_TOKEN outlived its ~48 minutes. Re-mint it and re-run; a spend that never proved never moved anything. |
refusing to start a half-configured MPC holder | One of the seven exports is missing or empty, and the message names which. This is what identity in Steps 2 and 3 is there to catch, before anything is registered. |
403 mentioning the onboarding allowlist | The wallet address is not allowlisted on the platform. It gates both registration and credential minting; nothing local can work around it. |
409 … a wallet cannot be both | An address is on a roster and being registered, or the reverse. Permanent for that address — use a different wallet. |
| A spend that waits, then times out | Nobody voted. The signature request is untouched; re-run after voting. The poll gives up after 5 minutes, which is shorter than most humans. |
"0x…" is an ADDRESS, not a wallet public key | An address passed to transfer. It wants the recipient's walletPub, from disburse-agent identity on their host — or the # W … line in their config file. Paying an address is disburse. |
… is a wallet PUBLIC KEY, not an address | The reverse: a walletPub passed to disburse. |
Recipients who run nothing at all
This page's recipient operates its own account, which is why it registers itself in Step 3. A payroll recipient who will never run the binary is a different arrangement: the funder adds their address to the roster and attests their (W, P) on their behalf, using a roster.attest credential — see Delegated Custody with DFNS.
The trade is explicit. Such an address is payable but cannot operate: holder credentials are minted only to a wallet that registered itself, and a rostered address is barred from registering. It can receive a disburse or a transfer and nothing else. Choose the roster route for people you pay, and Step 3 for accounts that need to spend.
Running the approval side unattended is disburse-agent approve, which needs a separate host and separate credentials — co-locating it with the agent gives you MPC with nothing examining the payload.