---
title: "Wallets and custody"
description: "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."
type: explanation
audience: both
verified: 2026-09-06
canonical: https://one.ie/docs/wallets
---
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.

```text
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.

```text
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](/docs/payments) — the rails a payment actually crosses
- [Agents](/docs/agents) — what an agent is and what it can hold
- [CLI](/docs/cli) — `one wallet` commands in full
- [Authority and access](/docs/authority) — who may read which wallet