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.
| Question | Where | Chains |
|---|---|---|
| Read a balance | network profile | Sui, EVM, Solana, Bitcoin |
Send (wallet:send) | pay link | SOL, ETH, BASE, ARB, OPT, BTC, SUI |
Invoice (wallet:invoice) | pay link | SUI, 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/wallethas 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 andwallet:get. Readwallet:getwhen 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:portfoliomakes 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. Usewallet:getto price one workspace.one wallet keygen --out <file>writes unencrypted key material to disk. It requires--yes-i-accept-plaintext-filefor 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
- CLI —
one walletcommands in full - Authority and access — who may read which wallet