Skip to main content

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:

  1. A funder — a wallet in DFNS custody that pays USDC out of its own balance into the privacy pool.
  2. 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 itWhat happens
disbursethe funder0.1 USDC leaves the funder's wallet and becomes a private note owned by the recipient
balancethe recipientdecrypts its own notes and reports what it owns
transferthe recipientmoves 0.04 of it to another account inside the pool — nothing on-chain names either party
sendthe recipientwithdraws 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 funderB — the recipient
DFNS walletits ownits own, a different one
Registered asFunding 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 minteddisburseholder.read, holder.transfer, holder.send
Config file~/.ligero/funder.env~/.ligero/agent.env
Needs a GPUnoyes, for transfer and send
You end up withEffort
Step 1Two custody wallets that can sign, both gated by an approval policy~1 h, once, in the DFNS dashboard
Step 2The funder registered, the disburse credential minted, funder.env written5 min + one approval vote
Step 3The recipient enrolled, three credentials minted, agent.env written5 min + one approval vote
Step 4Test USDC behind the funder, and the pool approved to spend it10 min + one approval vote
Step 5disburse, 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.

Amounts are always base units

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.

One thing is not yours to do

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.

The platform host, filled in

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
Why parts of this are clicks and not a script

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.

A registered key has no second copy

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).

IdentityGrantRole
provisionerPermissions:Create, PermissionAssignments:Create, PermissionAssignments:Read, Wallets:Create, Wallets:Read, Policies:Create, Policies:Read, Policies:UpdateBootstrap. Creates the wallets and policy, then never used again.
agentWallets:Read, Keys:Signatures:Create, Keys:Signatures:Read, Policies:Evaluations:ReadWhat the binary authenticates as. Requests signatures, reads the approval that gated them, can approve nothing.
Three permission names that look wrong and aren't
  • Policies:Evaluations:Read, not Policies:Approvals:Read. DFNS renamed the permission vocabulary without renaming the API, so the SDK still calls listApprovals while the operation string moved. The old name reads as correct and is rejected at role creation.
  • Keys:Signatures:Create, not Wallets:GenerateSignature. The latter is no longer published, and a role naming it is rejected wholesale with Request body failed validation.
  • Keys:Signatures:Read is not optional. On a gated wallet the first response to a signature request is always Pending, 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-….

On a trial, every operation pauses for a click

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.

A trial org is capped at ONE policy

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:

ValueVariable it becomesWhere it comes from
custody providerDISBURSE_HOLDER_SIGNERalways dfns here
API base URLDISBURSE_DFNS_API_BASE_URLhttps://api.dfns.io — a trial is on production, not staging
the walletDISBURSE_DFNS_HOLDER_WALLET_IDthe wa-… for this party's wallet, from step 4 above
its policyDISBURSE_DFNS_HOLDER_APPROVAL_POLICY_IDthe plc-… gating that wallet
credential idDISBURSE_DFNS_CRED_IDthe agent service account's base64url credential id, not its UUID
credential keyDISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILEdfns-agent.key from step 1 above
access tokenDISBURSE_DFNS_AUTH_TOKEN_FILEdfns-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.

Two files, one per party, and that is all

~/.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.

One variable name that looks wrong for a funder and isn't

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-file on both commands. Without it they read ~/.ligero/agent.env if it happens to exist, which would sign wallet A's registration with wallet B's custody. Your exports are the whole configuration here, deliberately.
  • identity first, 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 still onboard's to discover.
  • --signer dfns writes no funder private key at all, deliberately: under delegated custody the key never leaves the custodian, so the file gets DISBURSE_SIGNER=dfns and the provider's own variables are the block you appended.
  • --capabilities disburse mints 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-holder registers 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 of funder.env — that W is what Step 5's transfer sends 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.env exactly as written. Because you enrolled, onboard puts a DISBURSE_HOLDER_SIGNER=dfns line and an empty DISBURSE_HOLDER_VIEWING_KEY_FILE= there. Nothing the funder runs reads either one: the agent server and disburse use the funder half only. Filling them in is what would recreate the two-wallet-ids-in-one-file refusal, so don't.
Use two wallets that have never been on a payroll roster

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.)

Close this terminal when the step is done

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 new writes 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 --json is the whole job; the _FILE variables read that document as-is. Nothing can re-create it, and every note sealed to it is unreadable without it.
  • --login dfns signs the login inside the custodian — no key on this host. It is itself a Wallets:Sign, so it pauses for a vote: run this attended.
  • --enroll-holder is 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's disburse --to will resolve against. Wallet A did not, and could not, put wallet B on a list.
  • --platform is the site root, not the /agent-edge suffix the agent later dials.
  • The append is not a workaround. onboard writes DISBURSE_HOLDER_SIGNER=dfns and an empty DISBURSE_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_URL is on the last line for a reason: transfer and send do 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 own W — 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's transfer stops with DISBURSE_POOL_GATE_URL is required for a spend. The read credential that goes with it is minted in Step 5, because it expires.
The role name comes first, before any flag

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 want

It 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. approve sets 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.

Two unrelated things are both called "approve"

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​

Two parties, two terminals

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_ID collides 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 declared P → holder viewing key mismatch, naming two Ps 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 completed
done — 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 missing

The 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.

Why the recipient borrows the funder's key for this one read

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.

CommandCapability it readsMovesGPUWaits on a vote
disbursedisburse (DISBURSE_PLATFORM_TOKEN, held by the running agent)funder wallet → poolnoyes, the funder's policy
balanceholder.readnothingnono
transferholder.transferinside the poolyesyes, the holder's policy
sendholder.sendpool → a chain addressyesyes, 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.

The real environment always wins

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 optional

A 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.

The pool-gate block: vended to the agent server, and required by hand for a spend

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
CommandCapabilityCan it move value?
balanceholder.readNo
transferholder.transferInside the pool
sendholder.sendOut 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 seeWhat it means
no spendable USDC notes for this holderThe 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 < 100000The 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_IDOne 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 spendtransfer 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 whileThe 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 holderOne 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 allowlistThe 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 bothAn 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 outNobody 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 keyAn 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 addressThe 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.