Delegated Custody with DFNS
Getting Started runs the agent with the funder's private key in its own environment, which is fine for development and wrong for production. This page replaces that one piece: with DFNS, the funder key is MPC-sharded inside your DFNS organization and never exists on the agent host. There is no DISBURSE_FUNDER_PRIVATE_KEY anywhere in this track.
Everything else in Getting Started is unchanged — onboarding, minting a key, recipient eligibility, the /api/disburse call and its polling all work exactly as documented. What changes is step 3 (how the agent is configured, and that it is now two processes) and what happens in the middle of step 5.
Steps 1–5 cover the funder. Two other wallets can be custodied as well, and both need something the funder track doesn't: a holder spending its own balance and employees who never sign in.
Why there is a second process
Moving the key into MPC custody stops the agent from holding a funder key. On its own it does not stop the agent from asking for a signature over something it should not: DFNS signs whatever a permitted caller requests unless a policy says otherwise.
So the track has two halves:
- A policy in your DFNS org that pauses every funder signature and requests an approval.
- An approver — a second process you run, on a different host — that fetches the structured authorization the signature request refers to, recomputes the funder digest from it, checks it against a policy configured on its own host, and only then votes.
That second process is the same downloaded binary in a different role (disburse-agent approve). If the agent could approve its own requests the gate would be decorative, which is why the two halves get separate DFNS identities and separate hosts.
DFNS produces no signature until something votes. An agent configured for DFNS with no approver running does not fail fast — each batch waits, then times out. Stand the approver up before you send traffic.
1. Provision your DFNS organization
One-time. In the DFNS dashboard or API, create:
| What | Detail |
|---|---|
| A wallet on your target network | Must be secp256k1 for EVM chains. Its wa-... id is DISBURSE_DFNS_WALLET_ID; its address becomes your funder address. |
| Two identities | One for the agent, one for the approver. Each needs its own auth token and its own User Action Signing credential — a keypair whose private half you keep (DFNS registers the public half and returns a credential id). |
| A role for the agent | Wallets:Read, Keys:Signatures:Create, Keys:Signatures:Read, Policies:Evaluations:Read. Enough to create a signature request and read it back; not enough to approve anything. |
| A role for the approver | Policies:Evaluations:Read, Policies:Evaluations:Vote. Vote is the operation that decides. The agent must never hold it. |
| A gating policy | Activity kind Wallets:Sign, trigger AlwaysTrigger, action RequestApproval, with initiatorCanApprove: false and serviceAccountsCanApprove: false. Its plc-... id is DISBURSE_DFNS_APPROVAL_POLICY_ID. |
Two things here reliably cost people an afternoon:
The permissions you grant are Policies:Evaluations:Read and Policies:Evaluations:Vote. DFNS renamed the operation vocabulary without renaming the API, so the endpoints and SDK methods are still listApprovals / createApprovalDecision. The older spellings — Policies:Approvals:Read, Policies:GetApproval — read as correct and are rejected outright when you create the role.
POST /auth/service-accounts answers 401 "user token is missing or invalid" when the caller is itself a service account, even a full-admin one. Creating the two identities therefore needs an interactive dashboard session with a passkey. Everything after that — permissions, roles, wallets, policies — can be automated.
2. Fund the wallet
Send the DFNS wallet gas and the token you will disburse, then have it approve the pool as spender for that token.
approve(pool, amount) is a transaction signed by the DFNS wallet, so your new policy pauses it exactly like a disbursement. Expect to approve it — from the dashboard is fine, since the approver may not be configured yet.
3. Configure the agent
Replace the local-key line from Getting Started step 3 with the DFNS block. The platform variables are unchanged:
export DISBURSE_PLATFORM_URL="https://<platform-host>/agent-edge"
export DISBURSE_PLATFORM_TOKEN="<agent-edge token for your deployment>"
export DISBURSE_PLATFORM_VERIFY_URL="https://<platform-host>/api/employers/verify-key"
export DISBURSE_SIGNER="dfns"
export DISBURSE_DFNS_AUTH_TOKEN_FILE="/etc/ligero/dfns-agent.token"
export DISBURSE_DFNS_CRED_ID="<credential id of the agent's User Action Signing credential>"
export DISBURSE_DFNS_CREDENTIAL_PRIVATE_KEY_FILE="/etc/ligero/dfns-agent.key"
export DISBURSE_DFNS_WALLET_ID="wa-..."
export DISBURSE_DFNS_APPROVAL_POLICY_ID="plc-..."
disburse-agent
Two clarifications worth making explicitly:
- The credential private key is not the funder key. It signs the per-request User Action challenge that authenticates the API call. The funder key never leaves DFNS.
- The policy id is enforced, not declared. DFNS signs straight through when no policy matches a request, so the agent verifies that every signature it receives was gated by exactly this policy, and refuses one that was not. That check reads the approval, which is why the agent's role needs
Policies:Evaluations:Read. Without it, batches fail closed rather than settling on an unverified signature.
4. Configure and run the approver
On a different host, with only its own credentials. A separate cloud account is the pragmatic bar.
export DFNS_APPROVER_AUTH_TOKEN_FILE="/etc/ligero/dfns-approver.token"
export DFNS_APPROVER_CRED_ID="<the approver's credential id>"
export DFNS_APPROVER_CREDENTIAL_PRIVATE_KEY_FILE="/etc/ligero/dfns-approver.key"
export DFNS_APPROVER_USER_ID="us-..."
export DFNS_APPROVER_KEY_ID="key-..."
# where to read the authorization each signature request refers to — the AGENT's endpoint
export DFNS_APPROVER_NOTE_URL="https://<agent-host>/fund-auth/notes"
# the policy this gate enforces, set here and never derived from the request
export DFNS_APPROVER_FUNDER_ADDRESS="0x..."
export DFNS_APPROVER_CHAIN_ID="84532"
export DFNS_APPROVER_POOL_ADDRESS="0x..."
export DFNS_APPROVER_ALLOWED_TOKENS="0x..."
export DFNS_APPROVER_MAX_VALUE="1000000000"
export DFNS_APPROVER_MAX_COMMITMENTS="12"
disburse-agent approve
This role is outbound-only — it polls DFNS and fetches notes. It needs no inbound port, no TLS termination, no public DNS, no GPU, no chain access and no database, so it can sit entirely inside a private network. Being stateless per sweep, it also fits a scheduled serverless invocation rather than a long-running host.
DFNS_APPROVER_NOTE_URL is the one value that points at the agent. It is where the gate reads the authorization it recomputes the digest from, and an approver that cannot reach it decides nothing and every batch sits pending.
Both processes warn at startup if they can see the other's environment variables — that is the signature of both halves sharing a host, which defeats the separation the gate exists for.
5. Verify, then disburse
Readiness is the same probe as Getting Started, with one field that matters here:
curl http://127.0.0.1:8787/api/readyz
signerReachable: true is the DFNS-specific signal: your org answered and the wallet resolved. The approver logs one line per sweep, which is how you confirm it is watching before money moves.
The /api/disburse call and its polling are byte-for-byte what Disbursement documents. Only the middle of the job differs:
POST /api/disburse -> 202 accepted
agent proves the batch locally on its GPU
agent requests the funder signature from DFNS -> Pending, with an approvalId
approver sweeps, fetches the note, recomputes the digest, checks its policy, votes
DFNS produces the MPC signature -> agent verifies it passed THAT policy
agent hands the proved batch to the platform edge -> settles on-chain
GET /api/disburse/<key> -> done, with a txHash
Three operational limits to know before the first run:
- The agent waits five minutes per signature, then fails the batch rather than hanging. An unattended approver decides within one sweep interval, so this only bites when a human is in the loop.
- One approval per batch, not per payroll. Batch size is capped by
DISBURSE_MAX_FUND_BATCH, which the platform vends at boot, so a payroll larger than that limit becomes several signature requests and several approvals. - Some DFNS plans do not permit an unattended approver (service-account voting disabled, users passkey-only). Set
DFNS_APPROVER_ADVISORY=1there: the gate still fetches, recomputes and decides, but reports its verdict for a human to relay in the dashboard instead of submitting it. The check is real; only the final click is manual.
Steps 1–5 put the funder key in MPC custody, which is what a disbursement needs. Two other wallets in this system can be custodied too, and each needs something different from you.
A holder in DFNS custody
The funder pays other people. A holder operates on its own pool balance — balance, transfer, send. That authorization comes from a separate signer slot, DISBURSE_HOLDER_SIGNER, which is deliberately never defaulted from DISBURSE_SIGNER: funder custody and holder identity don't have to be the same key.
Setting it to dfns means no key that can move this holder's balance exists on the agent host.
export DISBURSE_HOLDER_SIGNER="dfns"
export DISBURSE_DFNS_HOLDER_WALLET_ID="wa-..." # the holder wallet — NOT the funder wallet
export DISBURSE_HOLDER_VIEWING_KEY="<escrowed; see below>"
# credentials default to the funder's DISBURSE_DFNS_* from step 3
Each credential variable takes a DISBURSE_DFNS_HOLDER_-prefixed override — _AUTH_TOKEN[_FILE], _CRED_ID, _CREDENTIAL_PRIVATE_KEY[_FILE], _ORG_ID, _API_BASE_URL — so the funder and the holder can be separate service accounts with different permissions. Sharing one is the default because most organizations have one.
The holder's service account needs Wallets:Read, and Keys:Signatures:Create + Keys:Signatures:Read to spend. A service account with only Wallets:Read can check a balance and cannot spend, which is a useful thing to deploy on its own.
The agent refuses at startup if DISBURSE_DFNS_HOLDER_WALLET_ID equals DISBURSE_DFNS_WALLET_ID. This isn't tidiness: the funder wallet's gating policy recomputes a funding authorization, so it would deny every spend request instead — and nothing in the resulting error would say why. Create a second DFNS wallet.
It needs an escrowed viewing key
This is the one requirement with no workaround. A holder's viewing key is normally derived from a signature over a fixed message, and MPC signatures are not reproducible, so there is no stable input to derive from. Mint the key yourself, escrow it, and enroll the printed P:
disburse-agent viewing-key new # prints it once, stores it nowhere
The full consequences — including the fact that losing this key strands the notes sealed to it, with no recovery path — are in Account Operations → The viewing key. Read that before you fund a DFNS holder.
Verify the identity before you fund anything
Everywhere else in this system, a holder's wallet public key W is recovered from a signature it produced — which is a self-consistency check for free. A DFNS holder's W is read from the wallet's published key instead, and that is exactly what makes MPC custody workable here: the non-determinism that breaks viewing-key derivation never touches the identity.
It also means a wallet id pointing at the wrong wallet of yours yields a key that is valid, on the curve, and someone else's. The agent checks the published key against the address DFNS reports for that same wallet and refuses a mismatch, but that check cannot tell you that you named the wrong wallet — only that the two halves of one record agree.
disburse-agent identity
Compare the printed W and address against the wallet in your DFNS dashboard. Do it before enrolling, not after a disbursement fails to show up.
What is and isn't gated
Set DISBURSE_DFNS_HOLDER_APPROVAL_POLICY_ID to a Wallets:Sign / RequestApproval policy scoped to the holder wallet, and each spend gets the same treatment a funder signature does: the agent publishes a structured approval note, passes its content-addressed reference as the DFNS externalId, and then verifies the signature it received actually passed that policy — refusing one DFNS produced without an approval. The service account also needs Policies:Evaluations:Read for that check.
Leave it unset — the default — and the agent still publishes the note, but nothing outside the agent recomputes what it signed. It warns at every boot. That is deliberate rather than an oversight: no approver process consumes holder spend notes yet, so a required policy with nothing voting would park every transfer in Pending until the poll timeout. That is an outage, not a gate.
The agent's own controls are unaffected either way, because they run before a signature is ever requested: the per-role ceilings (DISBURSE_TRANSFER_MAX_AMOUNT, DISBURSE_SEND_MAX_AMOUNT) and the pinned send destinations, which refuse a caller-supplied address even when it matches a pinned one.
One caveat if you do set a policy: the note is served over the agent's own endpoint, so an approver can only fetch it while a long-lived disburse-agent process is running. The one-shot spend commands exit as soon as they return, taking the note with them.
Employees in DFNS custody
Recipients don't run an agent, and normally you don't do anything for them: eligibility is written at their first sign-in, because that login signature is where their W comes from.
An employee whose wallet is in delegated custody signs nothing at onboarding. On EVM the address is a keccak hash that cannot be inverted, so there is no route from "here are my employees' DFNS wallets" to the W a roster needs — and worse, the first-login derivation that would give them a P can never produce a usable one either, because MPC signatures aren't reproducible. Such a recipient is permanently unpayable, and since the pre-proof resolver now refuses a disburse to a recipient with no registered P, you find out before paying to prove a note that would be rejected anyway.
Two commands close it. First, read W from the custodian:
disburse-agent wallet-pub --dfns-wallet-id wa-1abc...,wa-2def... > roster.csv
This needs only a Wallets:Read service-account token — it signs nothing, so it wants neither the credential key nor a wallet id of its own. Output is the CSV attest reads (walletAddress,walletPub); --json emits the API's own shape. It refuses any key whose derived address doesn't match the address the custodian reports, for the same reason the holder check exists: a key copied from the neighbouring row passes every hex and curve check and would attest an identity the employee does not control.
Then mint each employee's P, add it to the roster, and enroll in one pass:
walletAddress,walletPub,encryptionPublicKey
0xabc...,4f355bdc...(128 hex),9c1e7ab2...(128 hex)
export DISBURSE_ATTEST_TOKEN="<roster.attest-capability key>"
disburse-agent attest --file roster.csv --dry-run # checks the file, sends nothing
disburse-agent attest --file roster.csv
attest attaches each W, makes it eligible, and registers each P in the same run. The two live in one command because the order is load-bearing: P is refused for a wallet whose W isn't attested yet, so the registration has to follow the attestation of the same chunk. Re-running is safe — re-attaching the same W is a no-op and registering the same P is idempotent.
Its credential is roster.attest, deliberately not the disburse key: running payroll and deciding who may be paid are different powers, and fused, anything holding your payroll token could add a wallet to the eligibility set.
Whoever holds the private half of P can decrypt that subject's balance and history. When you supply it for an employee, that party is you. It cannot move their funds — the spend authorization does that, with their own signing key, which stays in their custody — but it is a genuine reduction in confidentiality against the employer, and the column is optional precisely so a roster that doesn't need it doesn't get it.
Replacing a P already on file is rotation and is refused with a 409, never silently applied: nothing re-encrypts historical notes, and the relayer serves only notes matching a subject's current P, so an overwrite would strand every note sealed to the old key while reporting success.
What this does and does not change
Privacy is identical. The settling transaction carries note commitments, and recipient identities never appear on chain. This track changes who holds the keys, not what is disclosed.
Fireblocks covers the funder only. Its adapter authorizes disbursements; there is no holder or roster equivalent for it today. Everything on this page after step 5 is DFNS-specific.