Wallet service (arrekta-wallet)
Watch-only, multi-party wallets with a policy engine. The service coordinates partially signed transactions (PST, PROTOCOL.md §16) between co-signers. It never holds private keys: keys stay on each co-signer's device, and the service sees only public keys and signatures.
Clients reach it through the gateway at /wallet/v1/* with an API key (X-API-Key: arrk_…).
The gateway passes on the key's id as the caller's identity (the principal) and
authenticates to the service with a shared token. The service is not exposed on any host
port, and it refuses every request when that token is not configured.
Concepts
| Term | Meaning |
|---|---|
| Co-signer | One party holding a key share: a person, an HSM, a cold device or a policy server. Each has an identity key (ML-DSA) that signs its key batches. It may name a gateway principal that acts for it. |
| Wallet | m-of-n over the co-signers (n = 1 is a single-key wallet). The owner is the principal that created it. |
| Address pool | Post-quantum keys have no xpubs, so each co-signer derives keys on its own device and registers them in signed batches. Address (chain, index) exists once every co-signer has a key for it. Chain 0 is receive, chain 1 is change. |
| Transfer | A payment moving through pending_approval → ready → signing → broadcast → confirmed. Other end states: denied, rejected, cancelled, expired, failed, dropped. |
| Policy | Rules and an approval quorum, evaluated on every transfer before it is built. |
Roles: the owner manages the policy. Operators (the owner and co-signer principals)
create transfers, submit signatures, and manage holds and the allowlist. Approvers
(listed in the policy) approve transfers and policy changes. Any member may freeze the
wallet. Non-members get 404 for everything.
Registering keys
Each co-signer signs its batch with its identity key, using the context
"ARRK-WALLET-KEYS-v1". The signed message is:
TH("wallet-keys", varstr wallet_id ‖ u32 cosigner ‖ varint n ‖
n × [u32 chain ‖ u32 index ‖ varbytes pubkey ‖ varint d ‖ d × u32 path])
TH is the tagged SHA3-256 hash (§2). The encoding follows §1. path is the hardened
derivation path as child indices, so m/9000'/0'/0'/1'/5' is [9000, 0, 0, 1, 5].
POST /wallet/v1/wallets/{id}/keys
{"cosigner": 0, "signature": "<hex>",
"keys": [{"chain": 0, "index": 0, "public_key": "<hex serialized pk>", "path": "m/9000'/0'/0'/0'/0'"}]}Batches signed by the wrong identity key are rejected (403 bad-attestation). So neither the
service nor an attacker who compromises it can slip a key into a co-signer's slot, which is
the multisig key-substitution attack that plain PSBT coordinators are exposed to. A public key
may appear only once per wallet (409 key-reuse). all_svc/wallet_check.py has a reference
implementation in Python (Device.batch).
Transfers
POST /wallet/v1/wallets/{id}/transfers
{"sequence_id": "invoice-1042", "recipients": [{"address": "rarrk1…", "amount": "100000000", "asset": "ARRK"}],
"memo": "invoice 1042", "fee_per_kb": 2000, "ttl_secs": 7200}sequence_idis unique per wallet. Repeating a create returns the original transfer (200), never a second payment.- The policy runs first. A denial returns
403 policy-deniedand is recorded. A required approval yieldspending_approval; otherwise the state isready. - Coin selection: oldest coins first. Coins that are immature, held, reserved by another live transfer, already spent in the mempool, or frozen for that asset are skipped. Fees use each input's real witness size. The inputs are reserved until the transfer ends, so two transfers never compete for the same coin.
- Change goes to the next unissued chain-1 address, falling back to chain 0.
- The response carries the PST (
pst, base64), per-input signing status, and anattestation: an ML-DSA-65 signature over the raw PST bytes with context"ARRK-PST-ATTEST-v1". Verify it with the key fromGET /wallet/v1/attestation-keyto detect tampering in transit.
Signing on a device. Decode the PST. Show summary(root): amounts leaving per asset,
change, and fee. Change is counted only when an output's key origin derives from your seed
to a key the output's lock commits to. Then sign every input whose origins derive to your
keys (sign_with_root) and submit:
POST /wallet/v1/transfers/{tid}/signatures {"pst": "<base64>"}The service verifies every incoming signature and merges them. When every input is
satisfied, it finalizes the transaction and broadcasts it (broadcast). If the node rejects
it, the transfer becomes failed and its coins are released. If the node is unavailable, the
signatures are kept (502 broadcast-deferred) and resubmitting retries. A worker marks the
transfer confirmed once mined, expired when the TTL passes before signing completes, and
dropped if the node no longer knows the transaction 30 minutes after broadcast.
Other actions:
POST /transfers/{tid}/approveand/rejectare for approvers. The initiator can never approve its own transfer.POST /transfers/{tid}/cancelis for the initiator or the owner.GET /transfers/{tid}andGET /wallets/{id}/transfers?state=…list transfers.
Signatures are refused until the approval quorum is met (409 approval-required).
Policy
{"rules": [
{"id": "big", "type": "max_amount", "asset": "ARRK", "amount": "500000000", "action": "require_approval"},
{"id": "daily", "type": "velocity", "asset": "ARRK", "amount": "5000000000", "window_secs": 86400, "action": "deny"},
{"id": "wl", "type": "allowlist", "action": "require_approval"},
{"id": "never", "type": "denylist", "addresses": ["rarrk1…"], "action": "deny"},
{"id": "ops", "type": "initiators", "principals": ["key_…"], "action": "deny"},
{"id": "risk", "type": "webhook", "url": "https://risk.example.com/approve", "action": "deny"}],
"approvers": ["key_a", "key_b"], "approvals_required": 1, "allowlist_cooldown_secs": 86400}- Every matching rule applies its action, and the strictest wins: deny beats require approval, which beats allow.
- Velocity counts this transfer plus every live transfer created within the window.
- Allowlist entries only count once their cooldown has passed (default 24 h, like exchange withdrawal allowlists). Removing an entry takes effect immediately.
- Webhook rules POST the transfer (recipients, amounts sent, initiator) to your server,
signed like gateway webhooks (
arrk-signature: v1,ml-dsa-65=…over"<timestamp>.<body>", contextARRK-WEBHOOK-v1, same attestation key). Reply200 {"approve": true}to approve. Any other answer, an error or a 10 s timeout counts as a denial (fail closed). Targets must be publichttpsURLs, redirects are not followed, and the DNS resolver refuses private addresses. Devnets allowhttpand private addresses.
Governance. PUT /wallets/{id}/policy applies immediately when the wallet has no
approvers. Otherwise it becomes a pending change that needs the approver quorum (the proposer
excluded) within 7 days (POST /policy-changes/{pid}/approve). A change goes stale if the
policy moved in the meantime. POST /wallets/{id}/policy/lock {"days": n} locks the policy
irreversibly until that date; after that PUT returns 423 policy-locked.
Other endpoints
| Endpoint | Purpose |
|---|---|
POST /wallets, GET /wallets, GET /wallets/{id} | Create a wallet (label, m, cosigners, policy) and list or read wallets, including address-pool levels |
POST /wallets/{id}/addresses/next {"chain": 0}, GET /wallets/{id}/addresses | Issue the next address (409 pool-exhausted when the pool is empty: register more keys) |
GET /wallets/{id}/balance, GET /wallets/{id}/unspents | Per asset: spendable, immature, reserved and pending-spend amounts; coin-level view |
POST /wallets/{id}/holds, DELETE /wallets/{id}/holds/{txid:vout} | Coin control: keep outputs out of automatic selection, optionally until an expiry |
POST/GET /wallets/{id}/allowlist, DELETE /wallets/{id}/allowlist/{address} | Allowlist with cooldown |
POST /wallets/{id}/freeze {"hours": n} | Emergency stop: no new transfers or signatures; cannot be lifted early |
GET /wallets/{id}/audit?after=&limit= | Append-only record of every action: keys, transfers, approvals, policy, freezes |
GET /attestation-key | The service's ML-DSA-65 public key (PST attestations and policy webhooks) |
POST /wallets/{id}/transfers with {"sequence_id", "pst"} | Import an unsigned PST built elsewhere (e.g. the issuer's freeze/clawback transactions). Non-clawback inputs must be free coins of this wallet; the wallet fills in redeem data and origins, applies its policy to outputs leaving the wallet, reserves the inputs, then collects signatures as usual |
Errors are application/problem+json objects with a stable code.
Operations
- Image and compose: image
arrekta/arrekta-wallet:local(arrekta-compose.sh build wallet), compose servicearrekta-walletin profileplatform, internal port 3004. - Database:
DWALLET_DB_*. - Settings:
ARREKTA_WALLET_SIGNING_SEED,ARREKTA_SERVICE_TOKEN(shared with the gateway) andARREKTA_WALLET_TRANSFER_TTL_SECS(default 7200). Runarrekta-compose.sh init-env --add-missingto add them to an existing.env. - Node rate limit: balances and unspents ask the node once per wallet address, which a
100-address treasury turns into 100 requests. With
ARREKTA_INTERNAL_TOKENset on both the node and the wallet service, the service sends it asx-arrekta-internaland skips the node's per-IP limit; without it, busy wallets get502 node-unavailableonce the limit is hit. - Checks:
core/.venv/bin/python all_svc/wallet_check.py(37 end-to-end checks on the devnet).