Skip to content
LogoLogo

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

TermMeaning
Co-signerOne 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.
Walletm-of-n over the co-signers (n = 1 is a single-key wallet). The owner is the principal that created it.
Address poolPost-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.
TransferA payment moving through pending_approval → ready → signing → broadcast → confirmed. Other end states: denied, rejected, cancelled, expired, failed, dropped.
PolicyRules 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_id is unique per wallet. Repeating a create returns the original transfer (200), never a second payment.
  • The policy runs first. A denial returns 403 policy-denied and is recorded. A required approval yields pending_approval; otherwise the state is ready.
  • 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 an attestation: an ML-DSA-65 signature over the raw PST bytes with context "ARRK-PST-ATTEST-v1". Verify it with the key from GET /wallet/v1/attestation-key to 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}/approve and /reject are for approvers. The initiator can never approve its own transfer.
  • POST /transfers/{tid}/cancel is for the initiator or the owner.
  • GET /transfers/{tid} and GET /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>", context ARRK-WEBHOOK-v1, same attestation key). Reply 200 {"approve": true} to approve. Any other answer, an error or a 10 s timeout counts as a denial (fail closed). Targets must be public https URLs, redirects are not followed, and the DNS resolver refuses private addresses. Devnets allow http and 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

EndpointPurpose
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}/addressesIssue the next address (409 pool-exhausted when the pool is empty: register more keys)
GET /wallets/{id}/balance, GET /wallets/{id}/unspentsPer 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-keyThe 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 service arrekta-wallet in profile platform, internal port 3004.
  • Database: DWALLET_DB_*.
  • Settings: ARREKTA_WALLET_SIGNING_SEED, ARREKTA_SERVICE_TOKEN (shared with the gateway) and ARREKTA_WALLET_TRANSFER_TTL_SECS (default 7200). Run arrekta-compose.sh init-env --add-missing to 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_TOKEN set on both the node and the wallet service, the service sends it as x-arrekta-internal and skips the node's per-IP limit; without it, busy wallets get 502 node-unavailable once the limit is hit.
  • Checks: core/.venv/bin/python all_svc/wallet_check.py (37 end-to-end checks on the devnet).