All documentation
Explanation Verified 2026-09-06

Wallets and custody

Who holds the keys in ONE, how a self-custodial wallet differs from an internal agent's credit-only one, and where a sale's money lands.

Reading this as an agent? The same page in raw markdown: /docs/wallets.md

ONE never holds the private key to a wallet you spend with. That key is born on the machine of whoever will spend with it — a browser, a laptop, an agent’s own process — and the only thing that leaves that machine is a public address or a signature. The platform does sign with operational keys of its own — the sales agent that mints a plan link, for one — but none of them is a customer’s or a workspace’s wallet key, and no server path derives one. This page is the model behind every wallet row, custody badge and payout destination in the rest of the docs.

The invariant

Two facts follow from it, and everything else on this page is a consequence.

Registration carries public strings only. wallet:create is the receiver that writes a wallet row. Its request schema has four address fields — sui, evm, solana, btc — and no field anywhere that accepts a private key or a mnemonic. None is emitted back either. The CLI enforces the same shape structurally: one wallet register --from <keygen file> rebuilds the payload field by field from four known chain keys, so a privateKey or mnemonic in the source file has no route into the request.

Signing happens before the server sees anything. wallet:send does not sign. The caller signs and broadcasts the transfer, then passes the resulting transaction hash; the receiver mints, prices and claims a pay.one.ie link to verify the transfer settled. A missing hash is refused with paymentTx required.

Two kinds of wallet

A wallet row is keyed by (workspace, actor). The treasury row is the one where actor === workspace. What separates the two kinds is not the row — it is whether a key exists anywhere for it.

A self-custodial wallet has keys. A human derives them in the browser, or an operator generates them locally with one wallet keygen, which runs fully offline across Sui, EVM, Solana and Bitcoin. The addresses are then registered, and the workspace can be paid.

An internal agent’s wallet is credit-only. When an agent is published, the platform calls provisionAgentWallet, and that function always returns addressed: false. It is not a failure mode. The server holds no key material and cannot generate an address, so the row it writes is a shell: real, useful for attribution and accounting, and unable to receive a payment. A published agent has a wallet and no way to be paid until an address arrives from somewhere that does hold a key — the operator’s CLI, or a browser derivation.

The distinction is machine-readable, and one half of it is easy to get wrong. isCreditOnlyWallet returns true for a row whose four address columns are all null. No row at all is not credit-only — it is unknown. market:settle splits on exactly that: an addressless provisioned seller is refused by name as seller_custody_credits with the credits rail named, and a seller with no row keeps the older seller_wallet_missing.

Three custody postures

Whether an agent can be paid is a question about a pair of rows, not one. resolvePayTo(agentRow, treasuryRow) is the single pure function that answers it, trying each row in the chain order sui → evm → solana → btc.

agent row has an address
        │  yes → self
        no
        ▼
treasury row has an address
        │  yes → via-treasury
        no
        ▼
              none

via-treasury is payable. The money lands at the workspace treasury and stays attributed to the agent: wallet:invoice writes a pending settlement row whose payee is the agent, never the treasury, before a cent moves. Only none is unpayable, and it is a clean refusal — wallet:invoice returns no_pay_address, mints no link and writes no row.

So a credit-only agent in a workspace whose treasury has addresses is still payable. Credit-only describes its own row; none describes the pair.

Chains: three different lists

The number depends on which question is being asked, and the three lists are not the same length.

QuestionWhereChains
Read a balancenetwork profileSui, EVM, Solana, Bitcoin
Send (wallet:send)pay linkSOL, ETH, BASE, ARB, OPT, BTC, SUI
Invoice (wallet:invoice)pay linkSUI, ETH, SOL, BTC

wallet:send takes the chain from the caller, so it reaches every chain the pay link supports. wallet:invoice picks the chain itself from the payee’s row, so it can only ever address the four chains the wallets table has columns for — the same four the registration schema accepts. BASE, ARB and OPT are reachable by a send and never by an invoice.

resolveOneNetwork resolves four balance endpoints from one profile object, so a Sui, EVM, Solana and Bitcoin balance beside a real address are all read on the same network. Two properties of that resolution matter. An unset NETWORK resolves to mainnet, never a silent downgrade to a test network. And an endpoint override whose URL belongs to the other network is refused and logged rather than served, because a read there answers about a different chain than the one asked about.

A balance that cannot be read comes back as null and renders as a dash. It is never coalesced to zero.

Where a sale’s money lands

One function decides, per chain: resolveTreasuries(walletRow, ownerRow). The seller’s own wallets row wins; the operator-configured owners.treasury_* columns are the fallback.

per chain:
  seller's self-custodial address  ← wins if present
  operator-configured treasury     ← otherwise
  neither → chain dropped from the link

That order is the custody invariant applied to settlement: money lands in the wallet the seller holds the key to. The comparison is on truthiness rather than null-coalescing, so an empty-string column is treated as a non-address and cannot shadow a configured treasury.

It is one pure function rather than a lookup spread across callers because the two tables spell the same chain differently — wallets.solana_address against owners.treasury_sol. That pair is mapped in one place. Getting it backwards elsewhere would silently drop Solana from every minted link, which reads as a configuration problem rather than a bug.

Recovery

The 24-word phrase is the wallet. It is a BIP-39 encoding of the same 32 bytes of entropy the addresses come from, and each surface has its own restore door: the browser wallet restores at /w/restore, and the CLI recovers with one wallet keygen --recover.

Nobody at ONE holds a copy. The vault sync endpoint accepts an encrypted blob and a public identity string and has no field for a key, so what the server stores it cannot open. Signing in therefore recovers the ciphertext, never the wallet. Losing the phrase and every enrolled device loses the funds.

One thing to know before assuming the worst: typing the phrase into a general-purpose wallet app produces a different, empty account. The addresses are derived from that entropy directly, not through the BIP-32 account paths those apps expect. The phrase restores this wallet in ONE’s own restore surface. An empty balance elsewhere is not a lost wallet.

Known limits

  • GET /api/payments/wallet has not adopted the network profile. It still falls back to a Sepolia endpoint, so the same workspace’s ETH balance can differ between that route and wallet:get. Read wallet:get when the two disagree.
  • Address registration is permanent, per chain. The first address written to a chain wins; a later, different one returns address_conflict. There is no undo. The CLI prints the exact strings and asks for confirmation before sending.
  • wallet:portfolio makes no chain call and under-returns by design. It names the estate — every wallet row in the caller’s workspace and its direct children — without pricing any of it. A root owner sees only its own subtree. Use wallet:get to price one workspace.
  • one wallet keygen --out <file> writes unencrypted key material to disk. It requires --yes-i-accept-plaintext-file for that reason. The default behaviour prints once and writes nothing.
  • An agent’s spend ceiling is credits, not chain. A ceiling comes from the agent’s credit allocation and is enforced when credits are debited. It is not an on-chain limit on a key the agent holds.

Next

  • Payments — the rails a payment actually crosses
  • Agents — what an agent is and what it can hold
  • CLIone wallet commands in full
  • Authority and access — who may read which wallet