Skip to main content

Account Operations

Disbursement covers paying other people. This page covers operating on your own pool balance: enrolling your wallet as a pool subject, reading what it can spend (balance), moving funds to another holder without leaving the pool (transfer), and withdrawing funds out to a chain address (send). All of them are driven by the same disbursement agent binary, in roles alongside the disburse role it already has — as commands, or as HTTP routes on the same process.

Nothing here is a new protocol capability: the funder wallet has always been a pool participant. What's new is that these operations are now reachable headlessly, with their own capability-scoped credentials, instead of only as a side effect of funding a payroll run.

Prerequisites

  • You've completed POST /api/employers/register (see Getting Started); account operations enroll that wallet as a pool subject, they don't create a new employer.
  • You can produce a signature from the wallet's key outside a browser, either by running the agent's own signer (disburse-agent identity, below) or by scripting the SIWX session handshake yourself. There's no single packaged command that does the full register-to-ready sequence yet; each step below is its own call.

Becoming a pool subject

Registering your own account and being able to hold funds in it are two different things. POST /api/employers/register creates your account; it does not touch W or P. To hold a balance, that wallet also has to enroll as a pool subject, which is what derives and registers W and P for the account itself, distinct from any recipient roster.

1. Derive your identity locally

disburse-agent identity

This reads no network and sends nothing; it derives W and P from whichever signer the agent is configured with (DISBURSE_HOLDER_SIGNER, see Configuring the agent) and prints them:

{
"platform": "evm",
"address": "0xAbC123...",
"walletPub": "a1b2c3...f0a1b2",
"encryptionPublicKey": "9f8e7d...c3b2a1",
"signer": "local-key",
"deterministic": true
}

address must match the wallet you'll authenticate as below; if it doesn't, you've pointed the agent at the wrong key.

2. Enroll

curl -X POST https://<platform-host>/api/employers/holder/onboard \
-H "Cookie: sessionId=<your-session-cookie>" \
-H "Content-Type: application/json" \
-d '{
"walletPub": "a1b2c3...f0a1b2",
"encryptionPublicKey": "9f8e7d...c3b2a1"
}'

The platform re-derives your wallet address from walletPub and checks it against the session; a mismatch is rejected. It then screens W through the same eligibility workflow recipients use.

{
"success": true,
"created": true,
"walletPub": "a1b2c3...f0a1b2",
"encryptionPublicKey": "9f8e7d...c3b2a1",
"readiness": "ready",
"eligibilityWhitelistedAt": "2026-08-17T12:00:00.000Z"
}
readinessMeaning
readyFundable now: W and P are registered and W is confirmed on the pool-gate whitelist.
eligibility_pendingRegistered; the gate confirmation hasn't landed yet. Re-POST the same call to re-drive it.
eligibility_failedThis request's own gate write just failed or was unreachable. Re-POST to retry.
eligibility_disabledThis deployment has no eligibility service configured; there's no gate to confirm against.
not_enrolledNo subject record exists yet.

Enrollment is idempotent on the same (W, P) pair and issues no credential of its own; that's a separate step, next.

3. Check readiness later

curl https://<platform-host>/api/employers/holder/enrollment \
-H "Cookie: sessionId=<your-session-cookie>"

Read-only: it reports the state as last written, and doesn't re-drive a pending gate confirmation on its own.

Rotating P

If you ever need to move to a different signer, POST /api/employers/holder/rotate-encryption-key (body: walletPub, currentEncryptionPublicKey, encryptionPublicKey) updates which key notes are sealed to going forward. Notes already sealed to the previous P still need that previous key to open, since rotating doesn't re-encrypt anything retroactively.

Minting a scoped credential

Credentials are capability-scoped: a key is minted for exactly one of disburse, holder.read, holder.transfer, or holder.send, and can only reach the routes that capability covers. A key that can transfer cannot also send, and vice versa; see Transfer and Withdraw for why that separation matters.

curl -X POST https://<platform-host>/api/employers/keys \
-H "Cookie: sessionId=<your-session-cookie>" \
-H "Content-Type: application/json" \
-d '{ "capability": "holder.transfer", "name": "treasury-transfer" }'
{
"success": true,
"id": 64,
"apiKey": "lgk_live_...",
"keyPrefix": "lgk_live_cd34",
"capability": "holder.transfer",
"name": "treasury-transfer",
"createdAt": "2026-08-17T12:00:00.000Z"
}

The plaintext apiKey is shown exactly once, same as any other key on this platform. Unlike the key /onboard used to mint, issuance here is additive: minting one doesn't revoke any other live key, so you can hold several at once (up to 50 per wallet) with different capabilities.

  • GET /api/employers/keys lists every key ever issued against your wallet, metadata only, revoked ones included.
  • DELETE /api/employers/keys/:id revokes one; it takes effect on the agent's next request, no redeploy needed.

Checking your balance

export DISBURSE_BALANCE_TOKEN="<holder.read-capability key>"

disburse-agent balance --token USDC
1500000 USDC (3 note(s), largest 1000000) on base_sepolia

The number is computed from your own decrypted notes, not from the platform's read of them: it sums the notes this holder can actually open, because value it cannot decrypt is value it cannot spend. It needs no GPU and moves nothing. notes is the count that total is spread across — the number of inputs a full-balance spend would prove over, which grows every time you receive change — and largestNote bounds the biggest single spend that needs no note consolidation.

balance carries its own credential, minted for the holder.read capability, and the agent refuses a value equal to either spend token. That guard runs the opposite way to the spend roles' and is worth understanding: a holder.transfer token can already read your notes, so permitting it here would work — and would mean no deployment can be read-only. A reporting job handed "the balance token" should hold something that genuinely cannot move value.

A zero balance on a holder you just paid usually means P is wrong

The platform filters notes by the subject's registered encryption key P before serving them, so a holder whose enrolled P isn't the one the agent computes sees exactly what an empty holder sees. If balance reports 0 after a confirmed disbursement, compare disburse-agent identity's encryptionPublicKey against what you enrolled before looking anywhere else.

Transfer (move funds within the pool)

An in-pool transfer moves value to another holder's W without it ever reaching an external address. It's the operation to reach for first when funds need to move between accounts you or your counterparties hold in the pool: payroll corrections, treasury rebalancing between subjects, anything that should stay inside the eligibility perimeter.

That containment doesn't make it a lesser-guarded operation. A transfer can sweep the holder's entire balance to any whitelisted W, including one an attacker controls, so it needs its own credential:

export DISBURSE_TRANSFER_TOKEN="<holder.transfer-capability key>"

disburse-agent transfer --token USDC --to a1b2c3...f0a1b2:500000
disburse-agent transfer --token <SYMBOL> --to <W>:<amount> [--to <W>:<amount> …] [--chain <key>] [--dry-run]

<W> is the recipient's wallet public key, not an address: a recipient who has never signed in has no W and can't receive a note. <amount> is in base units, and you can pass --to more than once to fan out a single transfer across several recipients.

{ "operation": "transfer", "status": "success", "txHash": "0x...", "nullifiers": ["0x..."], "changeAmount": "1500000" }

--dry-run builds the proof without submitting anything, useful as a stand-in balance check: a dry-run result with no error means the amount and recipients were spendable. DISBURSE_TRANSFER_MAX_AMOUNT sets a ceiling in base units.

Withdraw (move funds out of the pool)

External send moves value off-chain, to a real address, either back to the holder's own wallet (the default) or to a destination pinned in the agent's own configuration:

export DISBURSE_SEND_TOKEN="<holder.send-capability key>"
export DISBURSE_SEND_DESTINATIONS="treasury=0xAbC123...,ops=0xDeF456..."

# Self-withdraw: omit --to
disburse-agent send --token USDC --amount 500000

# Pay a pinned destination
disburse-agent send --token USDC --amount 500000 --to treasury
disburse-agent send --token <SYMBOL> --amount <base units> [--to <label>] [--chain <key>] [--dry-run]

--to takes a label from DISBURSE_SEND_DESTINATIONS, never a raw address — an address on the command line is refused outright, even if it happens to match a pinned one. That's deliberate: with no destinations configured, the worst case an unattended agent can produce is funds returning to the holder's own wallet, which is safe to run without supervision. Caller-supplied destinations aren't. A send takes exactly one destination per call.

{ "operation": "send", "status": "success", "txHash": "0x...", "nullifiers": ["0x..."], "changeAmount": "0" }

DISBURSE_SEND_MAX_AMOUNT sets a ceiling in base units, same shape as transfer's.

Driving these operations over HTTP

All three are also routes on the running agent, alongside the /api/disburse surface Disbursement documents. Same process, same loopback bind, same bearer auth:

MethodPathPurpose
GET/api/balance?token=USDCWhat this holder can spend in one token. No GPU, moves nothing.
POST/api/transferIn-pool transfer. Requires Idempotency-Key.
POST/api/sendWithdraw out of the pool. Requires Idempotency-Key.
curl -X POST http://127.0.0.1:8787/api/transfer \
-H "Authorization: Bearer lgk_live_..." \
-H "Idempotency-Key: correction-2026-08-1" \
-H "Content-Type: application/json" \
-d '{
"token": "USDC",
"recipients": [{ "walletPub": "a1b2c3...f0a1b2", "amount": "500000" }]
}'
curl -X POST http://127.0.0.1:8787/api/send \
-H "Authorization: Bearer lgk_live_..." \
-H "Idempotency-Key: treasury-sweep-2026-08-1" \
-H "Content-Type: application/json" \
-d '{ "token": "USDC", "amount": "500000", "to": "treasury" }'

Both accept "dryRun": true, which proves the whole pipeline and stops before submitting. to is a label from your pinned destinations, exactly as on the command line — an address in the body is refused even when it happens to be pinned. Omit to to withdraw to the holder's own wallet. Neither route takes a chain: it is the one the agent's holder is configured for, because accepting it per-request would let a caller aim a proof at a pool you never deployed against.

Both return the same receipt the CLI prints:

{ "operation": "transfer", "status": "success", "txHash": "0x...", "nullifiers": ["0x..."], "changeAmount": "1500000" }
These calls are synchronous and take minutes

Unlike /api/disburse, which hands back a job and a status URL, a spend returns its outcome on the same connection — a spend is one operation with one result, and a job envelope would add a state machine to something with exactly one thing to report. But it proves on a GPU and waits for on-chain finality first, so a default HTTP client will time out on it. Set a read timeout in minutes, not seconds.

Idempotency-Key is what makes that survivable. A caller whose socket dies mid-proof retries with the same key and attaches to the same in-flight spend rather than starting a second one, then reads the same outcome when it lands. Reusing a key with a different body is a 409.

The idempotency store is in-memory

It is per-process and does not survive a restart. A retry after the agent has been restarted is a new spend: the notes the first attempt consumed are already nullified, so it will pay again from different ones. If your caller cannot tolerate that, don't restart the agent with spends in flight — and treat a timeout across a restart as "go read the chain", not "retry".

Status codes are typed rather than generic, so a caller can branch on them:

StatuscodeMeaning
400unknown_tokenNot a pool symbol configured for this chain (a raw address is never accepted).
400unpinned_destinationto isn't a label in the agent's pinned allowlist. The message lists the labels that are.
403ceiling_exceededAbove this role's configured per-call ceiling. No retry changes that; raise it deliberately or split the spend.
409insufficient_fundsThe holder can't cover it. Carries what is actually spendable, which is not necessarily what you expected.
409idempotency_conflictThe key was reused with a different body.
422The spend settled and reverted. The body is the full receipt, including txHash and the revert reason: the request was well-formed and both the agent and the platform did their jobs, but the value did not move.

Configuring the agent for account operations

VariableRolePurpose
DISBURSE_BALANCE_TOKENbalanceCapability-scoped credential (holder.read). Required; refused if equal to either spend token or to DISBURSE_PLATFORM_TOKEN.
DISBURSE_TRANSFER_TOKENtransferCapability-scoped credential (holder.transfer). Required; refused if equal to DISBURSE_PLATFORM_TOKEN.
DISBURSE_SEND_TOKENsendCapability-scoped credential (holder.send). Required; refused if equal to DISBURSE_PLATFORM_TOKEN.
DISBURSE_SEND_DESTINATIONSsendlabel=address,label2=address2. Omit or leave empty to restrict sends to self-withdraw only.
DISBURSE_TRANSFER_MAX_AMOUNT / DISBURSE_SEND_MAX_AMOUNTtransfer / sendPer-call ceiling, base units.
DISBURSE_HOLDER_SIGNERall, and identitylocal-key (default), enclave, local-ed25519 (Solana/Stellar), or dfns. Not defaulted from DISBURSE_SIGNER on purpose: funder custody and holder identity don't have to be the same key, and sometimes cannot be.
DISBURSE_HOLDER_PRIVATE_KEYlocal-key onlyDev-oriented, same caution as the funder's local-key signer in Disbursement.
DISBURSE_HOLDER_VIEWING_KEYallAn escrowed viewing key, for a signer that cannot derive one. Required for dfns; see below.
DISBURSE_HOLDER_ENCRYPTION_PUBKEYallOptional cross-check: the P you enrolled. The agent refuses to start a spend if the configured viewing key doesn't produce it.

DISBURSE_PLATFORM_URL and the underlying pool-gate/edge settings are shared with the disburse role; see Disbursement → Configuring the agent if you haven't set those up yet.

DFNS is supported here; Fireblocks is not yet

DISBURSE_HOLDER_SIGNER=dfns puts the holder's spend key in MPC custody, so no key that can move this balance exists on the agent host — see Delegated Custody with DFNS → A holder in DFNS custody. It needs an escrowed viewing key, for the reason below. Fireblocks authorizes disbursements only; there is no holder adapter for it today, so don't assume the two signer settings are interchangeable.

The viewing key, and when you have to escrow it

Your viewing key is the private half of P. It reveals your balance and history and cannot move funds — the spend authorization does that, with a separate signature over the operation's own digest.

By default it is derived, from a signature over a fixed message. That only works if the signature is reproducible, which is true of a local key and of the signing enclave (both RFC 6979 deterministic) and false of any MPC signer: a threshold signer draws a fresh nonce every time, so the same message signed twice yields unrelated signatures and each session would derive a different viewing key, unable to read what the last one wrote. No configuration fixes that, because the input cannot be reproduced.

For those holders the key is generated once and escrowed by you instead:

disburse-agent viewing-key new    # prints it once, stores it nowhere

Put it in whatever secret manager already holds your API keys, point DISBURSE_HOLDER_VIEWING_KEY at it, and enroll the printed P.

Losing your viewing key strands the notes sealed to it

Ownership stays with your wallet key W on-chain, but a spend is built from note contents — amounts and nonces recovered by decrypting each note — and the nullifier that retires a note comes from a nonce that lives only in there. No decrypt, no nullifier, no proof. The relayer also serves only notes matching your current P, so notes under a previous key aren't offered either. There is no recovery path that turns "W still owns it" back into a spendable note.

A generated key cannot be re-derived from anything. Store it, restore it into a fresh shell, and confirm disburse-agent identity prints the same P before you fund the holder.

Two more things worth knowing: the agent never generates one for you — a missing secret is a startup error, never a fresh key, because a newly minted key would be perfectly valid and would open none of your notes. And one escrowed scalar serves every chain, though P still differs per curve; viewing-key new prints both, and you enroll the one for the chains you actually hold on.