Generated from docs/dev/ in the repository · every endpoint, command and operation on this page exists and has run

Overview

UNYKORN VAULT — developer documentation

This directory is the reference for building on, integrating with, and extending the custody system. Everything here describes code that exists in this repository and has run; where something is planned, it says so.

Document What it covers
ARCHITECTURE.md The three processes, what each may and may not do, the invariants, and the trust boundaries
API.md Every HTTP endpoint of the control plane (uny-api) and the signer (uny-signer), with request and response shapes
OPERATIONS.md The intent model — transfer, issue, authorize_holder, clawback, mpt_create, account_flags, contract_call — and how each maps onto XRPL, Stellar and an EVM Safe
POLICY.md The rule catalogue, deny-by-default, allow_operations, signed approvals, how a signer re-runs policy
KEYS.md Keystore format, key scope, the mainnet phrase, HSM backend, approver keys
INTEGRATION.md Adding a chain, adding a canonical asset, adding a signer backend, adding a policy rule, adding a guard
CLI.md uny command reference
RUNBOOK.md Bringing a fresh machine up: bootstrap, start, move, issue, stop; environment variables; file layout
openapi.yaml OpenAPI 3.1 for uny-api and uny-signer

The one-paragraph version

A control plane (uny-api) holds no keys. It accepts an intent — wallet, destination, amount, asset, operation — content-addresses it, runs the protections and the wallet's policy, collects signed approvals from registered approvers, and when the policy is satisfied asks each signer (uny-signer) for a signature. A signer holds one key set, listens on loopback, and before it signs anything it verifies the intent id, verifies the policy digest, verifies every approval signature against its own pinned registry, runs policy and the protections again itself, rebuilds the transaction from the inputs and compares the digest to the one it was asked to sign, checks the key's scope, and only then signs. The control plane assembles the signatures into the ledger's own multisig format, submits, waits for validation, and writes a receipt. Three hash-chained receipt logs — control plane, signer-a, signer-b — record every step.

Proof this runs

  • docs/e2e-2026-09-12/TRANSCRIPT.md — movements on XRPL testnet, Stellar testnet and a local EVM Safe, plus the refusals.
  • docs/e2e-2026-09-12/ISSUANCE-TRANSCRIPT.md — issuer operations (authorize, issue, clawback, MPT create, account flags) on XRPL and Stellar testnets, plus the refusals.
  • crates/uny-chains/tests/ — byte-identical encoding against ripple-binary-codec, @stellar/stellar-sdk and ethers.

Vocabulary

Term Meaning
intent The immutable, content-addressed request to do one thing on one ledger from one wallet
operation What the intent asks for; transfer by default
wallet descriptor The chain-side description of a wallet: chain, account/Safe address, signers with weights, quorum
policy set A versioned list of rules bound to one wallet; empty = refuse everything
signed approval An ed25519 signature by a registered approver over (approver, intent, verdict, time)
signer A uny-signer process holding one key set; a wallet's quorum is met by several
receipt One hash-chained, signed log entry; every process keeps its own chain
REAL / GATED / THIN Running and proven / built and blocked on a named item / designed, not built

Architecture

Architecture

Processes

┌──────────────────────────────┐   HTTP, loopback, bearer session   ┌────────────────────┐
│ operator surfaces            │ ─────────────────────────────────▶ │ uny-api            │
│  uny (CLI) · desktop shell   │                                    │ control plane      │
│  browser extension (GATED)   │ ◀───────────────────────────────── │ 127.0.0.1:7331     │
└──────────────────────────────┘                                    │ holds NO keys      │
                                                                    └──────┬─────┬───────┘
                          x-uny-signer-token, loopback, JSON               │     │
                 ┌─────────────────────────────────────────────────────────┘     └──────────────┐
                 ▼                                                                              ▼
       ┌────────────────────┐                                                        ┌────────────────────┐
       │ uny-signer  signer-a│                                                        │ uny-signer  signer-b│
       │ 127.0.0.1:7333      │                                                        │ 127.0.0.1:7334      │
       │ key set A (K1)      │                                                        │ key set B (K3)      │
       │ own receipt chain   │                                                        │ own receipt chain   │
       └────────────────────┘                                                        └────────────────────┘
                 │                                 ledgers                                      │
                 └─────────────▶  XRPL rippled · Stellar Horizon · EVM JSON-RPC  ◀──────────────┘
                                          (submission is done by uny-api)

Every process binds loopback. UNY_ALLOW_PUBLIC_BIND=i-understand is the only way to bind elsewhere and it is not used in any shipped configuration.

uny-api — the control plane

Owns state.json (wallets, policies, intents, velocity, receipts), the session, the approver registry it uses for weight, the list of signer endpoints, the RPC endpoints, and — for EVM — a gas-only relayer key that can pay for execTransaction but can never be a Safe owner.

It may: create intents, evaluate policy, verify and record signed approvals, ask signers to sign, assemble, submit, confirm, reconcile, and serve read views.

It may not: sign. It holds no wallet key. A fully compromised control plane can propose transactions; it cannot make a signer believe them.

uny-signer — one per key set

Owns one keystore (software: Argon2id → XChaCha20-Poly1305; hardware: YubiHSM 2), a map of chain addresses to key ids, its own copy of the approver registry, its own burned list, optional pinned policy digests, and its own receipt chain.

On every /sign it runs the sequence in The signer's decision. It never signs a caller-supplied digest.

uny (CLI) and the desktop shell

The CLI performs ceremonies that need a human at a keyboard (key generation, account setup, approvals, recovery rehearsal) and drives the API. The desktop shell spawns the built binaries and hosts the cockpit; approvals are signed in a CLI child process so the approver key never enters the renderer.

The signer's decision

uny_signer_core::SignerCore::sign, in order. Each step is a refusal on failure; the refusal is recorded in the signer's receipt chain.

  1. Intent integrityintent.verify_id(): the content address must match the body.
  2. Policy integritypolicy_digest(policy) must equal policy_digest in the request; if the signer pins a digest for this wallet, it must equal the pin.
  3. Approvals — every SignedApproval is verified against the signer's own registry. Weight is summed only from approvals that verify. Any verified rejection stops here.
  4. Policy — the wallet's policy set is evaluated by the signer with the weight it verified itself. Not Allow → refuse.
  5. Protectionsuny_chains::protect::evaluate with the control plane's inputs and the signer's own burned list merged in. Any Block finding → refuse.
  6. Rebuilduny_chains::build::prepare(intent, wallet, params); its digest must equal expected_payload_digest.
  7. Signer identitysigner_address must be one of the wallet's signers, and the signer must hold a key for it.
  8. Key scope — testnet-only keys refuse mainnet payloads; mainnet keys refuse a different asset or an amount above their cap.
  9. Sign, then self-verify the signature against the payload.
  10. Receiptpolicy_evaluated and signature_produced on the signer's chain.

The invariants

# Invariant Where it is enforced
1 Signers rebuild; they never sign a handed digest uny-signer-core step 6
2 Approvals are signatures; a rejection outranks approvals uny-keys::approval, step 3, uny-api::approve_signed
3 Policy runs three times (control plane, each signer) with independently verified weight uny-policy, step 4
4 Protections run inside the signer uny-chains::protect, step 5
5 Deny by default: no policy = no movement; unnamed operation = no operation PolicySet::evaluate
6 Software keys are testnet-only unless created with the typed phrase, an asset key and a cap uny-keys::KeyScope
7 An initiator may withdraw their own intent, never approve it uny-api::approve_signed
8 No wallet exists without a rehearsed recovery plan uny-store::create_native_wallet takes a ProvenRecoveryPlan, which has no public constructor
9 Secrets are never command-line arguments every CLI command reads passphrases from the environment
10 Every process writes its own hash-chained, signed receipts uny-receipts

Trust boundaries

Boundary Authenticated by What crosses it
operator → control plane operator passphrase → bearer session token (UNY_SESSION_TTL) intents, approvals, read views
control plane → signer per-signer token in x-uny-signer-token, compared constant-time SignRequest / SignResponse
approver → anyone ed25519 over sha256_domain("unykorn.vault.approval.v1", "approver|intent|verdict|at|strong_auth") SignedApproval
control plane → ledger none (public RPC) assembled transactions; validation is polled, a non-JSON answer is "not validated"

Content addressing and digests

Thing Domain string Preimage
intent id unykorn.vault.intent.v1 canonical JSON of every field except id; operation omitted when it is transfer
policy digest unykorn.vault.policy.v1 canonical JSON of the policy set
prepared tx (XRPL) unykorn.vault.prepared.xrpl.v1 the serialized transaction with an empty SigningPubKey
prepared tx (Stellar) unykorn.vault.prepared.stellar.v1 the transaction signature payload hash
prepared tx (Safe) unykorn.vault.prepared.safe.v1 the EIP-712 SafeTx hash
approval unykorn.vault.approval.v1 see above

Receipt chains

Three logs, same format (uny-receipts): each entry carries seq, at, actor, an event, prev and a signature; the head is sha256 over the chain. GET /ledger/verify walks the control plane's chain; GET /receipts on each signer returns its own. Verification needs only the public key, offline.

Events on the control plane: wallet_created, policy_applied, intent_created, policy_evaluated, approval_recorded, signature_produced, broadcast, confirmed, plus custom for refusals and administrative actions. On a signer: policy_evaluated, signature_produced, and custom for refusals.

What is deliberately absent

  • TSS / MPC ceremonies. Native on-ledger multisig gives the same "no single party" property with the ledger as referee; the maintained CGGMP21 crate carried a published advisory and its successor is pre-release.
  • A type-4 (EIP-7702) transaction path. None exists. A signer or relayer that carries a delegation is refused.
  • Partial payments on XRPL. tfPartialPayment is never set.
  • AUTH_IMMUTABLE on Stellar. Permanent; refused.
  • Admin roles on paired devices. approver, initiator, viewer only.

Operations

Operations — what an intent can ask a ledger to do

Every intent carries an operation. The default is transfer. Every other operation is an issuer action and is refused by policy unless the wallet's policy set names it in an allow_operations rule (POLICY.md). The chain layer (uny_chains::build::prepare) maps each operation onto the ledger's own primitive, and that mapping runs identically in the control plane and in every signer — a signer that receives an issue rebuilds the mint and refuses if the bytes differ.

operation destination means amount means zero amount
transfer recipient value moved refused
issue recipient (the holder) value minted refused
authorize_holder the holder being authorised (or revoked) ignored allowed
clawback the holder clawed back from value clawed back refused
mpt_create the wallet itself ignored allowed
account_flags the wallet itself ignored allowed
contract_call the contract called native value sent with the call allowed

issue, authorize_holder and clawback on XRPL IOUs and Stellar assets check that the wallet is the asset's issuer from the asset id itself, before any signer is asked. Issuing an asset whose issuer is somebody else is refused with issue of X requires the wallet to be its issuer.

JSON shapes

{"op":"transfer"}
{"op":"issue"}
{"op":"authorize_holder"}            {"op":"authorize_holder","revoke":true}
{"op":"clawback"}
{"op":"mpt_create","asset_scale":6,"maximum_amount":1000000000000,"transfer_fee":null,"flags":100,"metadata_hex":"7b22…7d"}
{"op":"account_flags","set":11}      {"op":"account_flags","set":2,"clear":null}
{"op":"contract_call","data_hex":"0x8456cb59"}

Per ledger

XRPL (xrp, xrp-testnet)

Asset id in asset_issuer: ACCOUNT.CODE for an issued currency (CODE is 3 chars, 4–20 printable chars, or 40 hex) or a 48-hex MPT issuance id.

operation transaction built notes
transfer Payment (XRP, IOU or MPT amount) tfPartialPayment never set; numeric memoDestinationTag
issue Payment from the issuer IOU: Amount.issuer = wallet. MPT: Amount = {mpt_issuance_id, value}; the holder must have opted in (MPTokenAuthorize) and, if RequireAuth, been authorised
authorize_holder IOU: TrustSet with tfSetfAuth, LimitAmount = {0, CODE, issuer: holder} · MPT: MPTokenAuthorize with Holder revoke on an IOU line = tfSetFreeze (the ledger has no un-auth); on an MPT = tfMPTUnauthorize
clawback Clawback IOU: Amount.issuer = holder (ledger convention) · MPT: Amount + Holder. Needs lsfAllowTrustLineClawback on the issuer (IOU) or tfMPTCanClawback on the issuance
mpt_create MPTokenIssuanceCreate flags: 0x02 CanLock · 0x04 RequireAuth · 0x08 CanEscrow · 0x10 CanTrade · 0x20 CanTransfer · 0x40 CanClawback. Permanent. execute returns mptIssuanceId = sequence ‖ issuer account id
account_flags AccountSet set / clear are asf numbers (one each per tx): 1 RequireDest · 2 RequireAuth · 3 DisallowXRP · 4 DisableMaster · 6 NoFreeze · 7 GlobalFreeze · 8 DefaultRipple · 9 DepositAuth · 16 AllowTrustLineClawback
contract_call refused: not an XRPL operation

Every multisigned transaction pays base_fee × (1 + signer_count) and carries a uny/intent memo with the intent id.

Issuer setup order matters (uny native setup-xrpl --issuer): AllowTrustLineClawback must be set while the owner directory is empty, then DefaultRipple, then RequireAuth, then the signer list; the master key is disabled only after a multisigned no-op validates.

Holder side (uny native holder): TrustSet (IOU) or MPTokenAuthorize (MPT opt-in), single-signed by the holder's own key.

Stellar (xlm, xlm-testnet)

Asset id: CODE:ISSUER. Decimals are always 7.

operation operation built notes
transfer Payment text memo ≤ 28 bytes
issue Payment from the issuing account holder needs a ChangeTrust line first; if the issuer has AUTH_REQUIRED, an authorize_holder first
authorize_holder SetTrustLineFlags set AUTHORIZED (1) · revoke clears it needs AUTH_REVOCABLE on the issuer to revoke
clawback Clawback needs AUTH_CLAWBACK_ENABLED on the issuer at the time the trust line was created — lines opened before the flag are not clawable
account_flags SetOptions bit masks: 1 AUTH_REQUIRED · 2 AUTH_REVOCABLE · 8 AUTH_CLAWBACK_ENABLED. 4 AUTH_IMMUTABLE is refused (permanent)
mpt_create, contract_call refused

Holder side: uny native holder --chain xlm-testnet --asset CODE:ISSUER = ChangeTrust.

EVM Safe (eth, base, polygon, arbitrum, avalanche, optimism, their testnets, evm-local)

The wallet is a Safe v1.4.1. Every operation becomes one SafeTx (to, value, data, operation=CALL, nonce) signed EIP-712 by the owners, packed sorted by owner address, and wrapped in execTransaction by a gas-only relayer key.

operation SafeTx notes
transfer native: to=destination, value=amount · token: to=token, data=transfer(destination, amount)
issue to=token, data=mint(destination, amount) (selector 0x40c10f19) the Safe must hold the token's minter role (OpenZeppelin AccessControl / ERC-3643 agent)
contract_call to=destination, value=amount, data=data_hex asset must be native; ≥ 4 bytes of calldata. This is how pause, freeze, role grants and any ERC-3643 compliance call are made
authorize_holder, clawback, mpt_create, account_flags refused: use contract_call with the token's own method (e.g. ERC-3643 forcedTransfer, setAddressFrozen)

The relayer is refused if it carries an EIP-7702 delegation; a Safe owner that carries one is refused by the guard and flagged as critical drift by /reconcile.

What goes into the receipt

The operation name is on every intent-related receipt (intent_created, policy_evaluated, signature_produced, confirmed) and in GET /intents. Velocity ledgers count only value-carrying operations (transfer, issue, clawback, contract_call).

Worked example — a new stablecoin on XRPL testnet, start to finish

# issuer wallet already set up with --issuer flags and a signer list (bootstrap-testnet.ps1)
# policy: add {"rule":"allow_operations","operations":["issue","authorize_holder","clawback","mpt_create","account_flags"],"required_weight":1}

# 1. the client's holder key opts in
uny native holder --keystore client.json --key holder --chain xrp-testnet --asset rISSUER.UNYD --limit 1000000

# 2. issuer authorises the holder (intent → approval → two signers → ledger)
POST /intent {"wallet":"wal_treasury-xrp","destination":"rHOLDER","amount":"0",
              "asset_symbol":"UNYD","asset_issuer":"rISSUER.UNYD","asset_decimals":6,"operation":{"op":"authorize_holder"}}

# 3. mint 1,000 UNYD
POST /intent {… "amount":"1000000000","operation":{"op":"issue"}}

# 4. claw back 250 (compliance action)
POST /intent {… "amount":"250000000","operation":{"op":"clawback"}}

The same sequence for an MPT: mpt_create (destination = the issuer) → read mptIssuanceId → holder uny native holder --asset <id>authorize_holderissue, with asset_issuer = the issuance id.

Proof of each step on the public testnets: docs/e2e-2026-09-12/ISSUANCE-TRANSCRIPT.md.

HTTP API

HTTP API reference

Two services. Both speak JSON, both bind loopback, both refuse anything else.

Service Default bind Auth
uny-api (control plane) 127.0.0.1:7331 Authorization: Bearer <session token> from POST /session/unlock
uny-signer (one per key set) 127.0.0.1:7333, 7334, … x-uny-signer-token: <token> (constant-time compare; ≥ 24 chars)

Errors are JSON-RPC-shaped: {"error":{"code":<int>,"message":"…","data":null}}. Codes: 4000 bad request · 4100 session invalid or expired · 4040 not found · -32602 refused (policy, guard, state) · -32603 internal / RPC.

Amounts are integer minor units as decimal strings everywhere: drops, stroops, token units (10^decimals). "1500000" is 1.5 XRP.

The machine-readable version of this page is openapi.yaml.


uny-api

Session

POST /session/unlock

Body {"passphrase": "<operator passphrase>"} — compared as SHA-256 against UNY_PASSPHRASE_SHA256. Returns {"token": "…", "expires_at": <unix>, "accounts": ["…"]}. Tokens live in memory only and expire after UNY_SESSION_TTL seconds; a restart locks the vault.

POST /session/lock

Invalidates the caller's token. {"locked": true}.

GET /health (no auth)

{"service":"uny-api","version":"1.0.0","enterprise":"ent_unykorn-llc",
 "receipts":172,"ledger_head":"8eed…","signer_fingerprint":"286E84F96DCFE4FB","chain_verified":true}

Wallets and policy

GET /wallets

Array of

{"id":"wal_treasury-xrp","name":"UnyKorn Treasury (XRPL testnet)","chain":"xrp-testnet",
 "keyBackend":"NativeMultisig","quorum":"3-of-4","topology":"K1 ops (r4Vc…)×1, K3 policy (rGQF…)×2",
 "address":"rpP4bKgADf2d4JtsCYegPB1i9q98NBosyu",
 "recoveryProven":true,"recoveryRehearsedAt":"2026-09-12T07:34:45Z",
 "policyVersion":3,"policyRuleCount":3}

Wallets are created by the CLI (uny native create / uny wallet create), never over HTTP, because creation requires a rehearsed recovery plan.

GET /policy/{wallet}

{"wallet":"wal_treasury-xrp","version":3,"ruleCount":3,"deniesAll":false,
 "rules":[{"rule":"approval_threshold","asset":{…},"above":"0","required_weight":1},
          {"rule":"hard_ceiling","asset":{…},"max":"50000000"},
          {"rule":"allow_operations","operations":["issue","authorize_holder","clawback","mpt_create","account_flags"],"required_weight":1}]}

Policies are applied with uny wallet apply-policy followed by POST /admin/reload. See POLICY.md for every rule.

GET /assets

{"assets":[CanonicalAsset…],"reservedTickers":["USDC","USDT",…]} — the pinned issuer registry the counterfeit guard checks against.

Intents

POST /intent

Create an intent. Content-addressed: submitting the same body (same second) returns the original.

{
  "wallet": "wal_treasury-xrp",
  "destination": "rs15Zqfbgk1RQSt9XhHv5AvtJEvbuYp9tF",
  "amount": "1000000000",
  "asset_symbol": "UNYD",
  "asset_issuer": "rpP4bKgADf2d4JtsCYegPB1i9q98NBosyu.UNYD",
  "asset_decimals": 6,
  "memo": "optional; XRPL destination tag if numeric, Stellar text memo ≤ 28 bytes",
  "initiator": "operator",
  "tags": [["deal","m-helen"]],
  "operation": {"op": "issue"}
}
Field Notes
asset_symbol / asset_issuer / asset_decimals Omit all three for the chain's native asset. Issuer formats: XRPL ACCOUNT.CODE or a 48-hex MPT issuance id; Stellar CODE:ISSUER; EVM the token contract. Stellar assets must say 7 decimals.
operation Default {"op":"transfer"}. Others: issue, authorize_holder (revoke), clawback, mpt_create (asset_scale, maximum_amount, transfer_fee, flags, metadata_hex), account_flags (set, clear), contract_call (data_hex). See OPERATIONS.md. amount may be "0" only for authorize_holder, mpt_create, account_flags, contract_call.
initiator Recorded on the intent; the initiator can later reject but not approve.

Response:

{"intentId":"int_fd22…","status":"pending_approvals",
 "decision":{"decision":"require_approvals","required_weight":1,"current_weight":0,
             "reasons":["issue on this wallet demands approval weight 1"]},
 "receipt_head":"…"}

statusallowed · pending_approvals · timelocked · denied. A denied intent is still stored and receipted.

GET /intent/{id}

{"intentId":"…","wallet":"wal_treasury-xrp","destination":"rs15…","amount":"500000000",
 "asset":"xrp-testnet:UNYD:013BB794…","operation":{"op":"issue"},"status":"confirmed",
 "decision":{"decision":"allow"},
 "approvals":[{"approver":{"id":"usr_counsel","kind":"user"},"at":1789232588,"strong_auth":true,"weight":1}],
 "custodianRef":null,"txHash":"E50D…"}

GET /intents

{"intents":[{"intentId","wallet","chain","destination","amount":"500 UNYD","operation":"issue","asset","status","txHash","signedApprovals","signatures","createdAt"}…]} — newest first, 100 max.

GET /intent/{id}/protect

Runs the protections with live chain inputs and returns them without executing: {"intentId":"…","blocked":false,"findings":[{"guard":"counterfeit_asset","severity":"block","detail":"…"}]}. Guards: network_separation, counterfeit_asset, address_poisoning, burned_key (also covers an EIP-7702-delegated destination or signer). Severity block or warn.

POST /intent/{id}/approve-signed

Body is a SignedApproval exactly as uny approver sign prints it:

{"approver_id":"counsel","intent":"int_…","verdict":"approve","at":1789232588,"strong_auth":true,
 "signature":{"curve":"ed25519","sig":"<64-byte hex>"}}

The control plane verifies it against UNY_APPROVERS, adds the approver's weight, re-runs policy. verdict:"reject" denies the intent permanently; the initiator may reject their own intent but an approval from the initiator is refused. Returns {"intentId","status","signedApprovals":1,"decision":{…}} or {"intentId","status":"denied","rejectedBy":"kevan"}.

POST /intent/{id}/approve (legacy, unsigned)

{"approver":"counsel","weight":1,"strong_auth":true}. Records an approval the signers will not count — signers verify only signed approvals. Kept for the extension pairing flow; do not build on it.

POST /intent/{id}/execute

The whole pipeline: fetch ledger params → protections → prepare → ask each signer endpoint that holds one of the wallet's signer addresses → verify each signature → stop if weight < quorum → assemble → submit → wait for validation → receipts.

{"intentId":"…","status":"confirmed","txHash":"E50D…","chain":"xrp-testnet","operation":"issue",
 "summary":"Payment rpP4… → rs15… of 500000000 units of MPT 013B…",
 "weight":3,"quorum":3,
 "signers":[{"signer":"signer-a","address":"r4Vc…","ok":true,"detail":"…"},
            {"signer":"signer-b","address":"rGQF…","ok":true,"detail":"…"}],
 "submit":{"engine_result":"tesSUCCESS"},"receiptHead":"…",
 "mptIssuanceId":"013BB7940F334D03C35934FFC86FCB5124BA1B00FBDB7D65"}

mptIssuanceId is present only after an mpt_create (sequence ‖ issuer account id; the ledger's meta.mpt_issuance_id is the same value). Other outcomes: {"status":"awaiting_signatures","weight":1,"quorum":3,"signers":[…]} when a signer refused; {"status":"denied","findings":[…]} when a guard blocked; an error when the ledger rejected.

Estate and reconciliation

GET /estate

{"asOf":"2026-09-12T17:11:06Z",
 "balances":[{"wallet":"wal_treasury-xrp","label":"…","chain":"xrp-testnet","address":"rpP4…","asset":"XRP","amount":"98.49967","canonical":true}],
 "errors":["wal_treasury-evm: rpc: eth_getBalance: …"]}

GET /wallets/{id}/reconcile

Verifies control and history against the live ledger:

{"wallet":"wal_treasury-xrp","chain":"xrp-testnet","address":"rpP4…","asOf":"…","clean":true,
 "checked":["account exists","signer list present","5 confirmed intents verified on chain"],
 "drift":[{"severity":"warn","detail":"master key is still enabled; the signer list is not the only control"}]}

Drift severities: warn, critical (e.g. an owner carrying an EIP-7702 delegation, a missing signer, a threshold that does not match the descriptor). Balance-vs-accounting reconciliation is not here yet (THIN).

Receipts

GET /ledger/verify

{"verified":true,"receipts":172,"head":"…","signerPublicKey":"…"}.

GET /ledger/recent?limit=50

{"receipts":[{"seq","at","actor","event":{…},"digest"}…]}.

Administration

POST /admin/reload

Re-reads state.json from disk (after uny wallet apply-policy or uny native create). {"reloaded":true,"wallets":3,"receipts":172}.

POST /snapshot

Everything the browser extension popup needs in one call. Fields marked as not wired return empty values.

Pairing and devices (browser extension, GATED)

POST /pair/create {"label","subject","role":"approver|initiator|viewer","endpoint"}{"code","challenge",…} · GET /pair/qr/{code} · POST /pair/claim {"code","challenge","device_public_key"} (unauthenticated by design) · GET /devices · POST /devices/{id}/revoke. There is no admin role for a paired device.

GET /recovery/status

Per wallet: the recovery tier, proof digest, rehearsal date, drill intervals.


uny-signer

Configured by UNY_SIGNER_CONFIG (JSON):

{"signer_id":"signer-a",
 "backend":{"kind":"software","keystore":"keys.json"},
 "keys":{"r4Vc…":"k1","GBTB…":"k1","0xB534…":"evm-k1"},
 "approvers":"../approvers.json",
 "pinned_policies":{"wal_treasury-xrp":"<policy digest hex>"},
 "burned":["0x8aced25d…"],
 "bind":"127.0.0.1:7333",
 "receipts":"receipts.json","receipt_seed":"ledger.seed",
 "enterprise":"unykorn-llc"}

backend.kind is software (keystore path; passphrase from UNY_SIGNER_PASSPHRASE) or yubihsm (config path to an HsmConfig; password from UNY_HSM_PASSWORD). See KEYS.md.

GET /health (no auth)

{"signer_id":"signer-a","backend":"software","ok":true,"receipts":37,"receipt_chain_verified":true,"receipt_head":"…","receipt_signer_fingerprint":"…"}

GET /keys

Array of {"signer_address","key_id","curve","public_key_hex","scope":{"scope":"testnet_only"}|{"scope":"mainnet","asset_key":"xrp:XRP","cap_minor_units":"…"}}.

POST /sign

Body is a SignRequest:

{"intent":{…TransferIntent…},
 "wallet":{"wallet":"wal_treasury-xrp","chain":"xrpl-testnet","control":{"kind":"native_multisig","address":"rpP4…","signers":[{"address":"r4Vc…","curve":"ed25519","weight":1,"label":"K1 ops"},…],"quorum":3}},
 "policy":{…PolicySet…},"policy_digest":"<hex>",
 "approvals":[…SignedApproval…],
 "params":{"kind":"xrpl","sequence":20690833,"base_fee_drops":10,"last_ledger_sequence":4642000,"network_id":null},
 "protect":{"known_destinations":[…],"burned":[…],"evm_code":[],"token_owner_burned":false,"onchain_symbol":null,"compliance_cleared":true,"attestation_valid":true},
 "expected_payload_digest":"<hex>",
 "signer_address":"r4Vc…"}

params variants (tagged by kind): {"kind":"xrpl",sequence,base_fee_drops,last_ledger_sequence,network_id} · {"kind":"stellar",sequence,base_fee_stroops,max_time} · {"kind":"evm_safe",chain_id,safe_nonce}.

Response (SignResponse):

{"signature":{"signer_address":"r4Vc…","public_key":{"curve":"ed25519","bytes":"…"},"signature":{"curve":"ed25519","sig":"…"}},
 "backend":"software","receipt_head":"…","warnings":[],"summary":"Payment rpP4… → …"}

A refusal is HTTP 403 with {"refused":{"reason":"<kind>",…},"message":"…"}; kinds: intent_tampered, policy_mismatch, approvals, rejected, policy_denied, protected, digest_mismatch, not_a_signer, key, chain.

GET /receipts?limit=20

{"verified":true,"recent":[{"attested":{"digest","payload":{"actor","at","enterprise","event","prev","seq"},…}}…]} — the signer's own chain, newest first.


Talking to it from code

TOK=$(curl -s -X POST 127.0.0.1:7331/session/unlock -H 'content-type: application/json' \
      -d '{"passphrase":"'"$UNY_OPERATOR_PASSPHRASE"'"}' | jq -r .token)

# 1. intent
ID=$(curl -s -X POST 127.0.0.1:7331/intent -H "authorization: Bearer $TOK" -H 'content-type: application/json' \
  -d '{"wallet":"wal_treasury-xrp","destination":"rs15…","amount":"1000000000",
       "asset_symbol":"UNYD","asset_issuer":"rpP4….UNYD","asset_decimals":6,"operation":{"op":"issue"}}' | jq -r .intentId)

# 2. a named approver signs, on their own machine, with their own key
AP=$(UNY_KEYSTORE_PASSPHRASE=… uny approver sign --keystore approver.json --id counsel --intent "$ID" --strong-auth)
curl -s -X POST "127.0.0.1:7331/intent/$ID/approve-signed" -H "authorization: Bearer $TOK" -H 'content-type: application/json' -d "$AP"

# 3. execute: signers rebuild and sign, the control plane assembles and submits
curl -s -X POST "127.0.0.1:7331/intent/$ID/execute" -H "authorization: Bearer $TOK"

docs/e2e-2026-09-12/issuance-flow.sh is this loop as a script.

Policy

Policy

uny-policy evaluates one intent against one wallet's PolicySet and returns a Decision. It runs in the control plane at intent creation and after every approval, and again inside each signer before signing. The signer's run uses only the approval weight it verified itself.

Decisions

decision intent status meaning
allow allowed every rule passed, approvals gathered, no delay pending
require_approvals {required_weight, current_weight, reasons} pending_approvals more signed approval weight is needed
delay {not_before, reasons} timelocked a timelock rule holds it until not_before
deny {reasons} denied one or more rules refused; approvals cannot cure it

Precedence: any deny wins; then approvals; then delay. A rule never sees another rule's verdict, so no rule can weaken another.

Structural rules (not configurable)

  1. Empty policy set = deny. A wallet with no rules refuses every movement.
  2. Unnamed operation = deny. An intent whose operation is not transfer is refused unless an allow_operations rule in the set names it — before any other rule runs.
  3. Wallet mismatch = error. A set bound to wallet A cannot evaluate an intent on B.

Rule catalogue

Rules are JSON objects tagged by rule. asset is an AssetSpec ({"chain","symbol","decimals","issuer"}); amounts are minor-unit decimal strings.

rule fields verdict
always_allow pass. Tests and deliberately open faucets only
require_compliance_clearance deny unless the control plane's screening result is cleared; a screening outage denies
destination_allowlist addresses[], allow_empty_as_open deny unless destination is listed (case-insensitive); empty list is open or closed per the flag
destination_blocklist addresses[] deny if listed, regardless of allowlist
daily_velocity_cap asset, cap, window_secs deny when cumulative confirmed outflow of asset in the window plus this amount exceeds cap; 0 = no cap
approval_threshold asset, above, required_weight needs approvals when amount of asset > above
timelock_above asset, above, delay_secs delay until created_at + delay_secs when amount > above
hard_ceiling asset, max deny when amount > max; no approval path
require_attestation subject deny unless the intent's attestation verified
require_human_approver_for_agent_initiated above_weight when the initiator is an agent, needs above_weight from strongly-authenticated human approvers
agent_budget_ceiling deny when an agent initiator's remaining budget is below the amount
operating_window_utc start_secs, end_secs, override_weight outside the window: deny, or need override_weight approvals if > 0
require_tag key deny unless the intent carries the tag
allow_operations operations[], required_weight permits the named issuer operations on this wallet; each demands required_weight approvals (0 = none beyond other rules). Names: issue, authorize_holder, clawback, mpt_create, account_flags, contract_call

Rules scoped to an asset do not fire for other assets. A treasury policy written for XRP says nothing about a UNYD mint — which is exactly why allow_operations exists.

Baselines

uny_policy::baseline_policy(wallet, chain, version) — a native-asset treasury: compliance clearance, open allowlist, zero daily cap (operator must set a number), approval weight 2 above zero, human approver for agent-initiated. Does not permit issuer operations.

uny_issuance_flow::baseline_issuance_policy(wallet, instrument, version) — an issuing wallet: the same, scoped to the instrument's asset, plus allow_operations {["issue"], required_weight: 2}.

Approvals

An approval is a SignedApproval:

message = sha256_domain("unykorn.vault.approval.v1",
                        approver_id | intent_id | verdict | at | strong_auth)
signature = ed25519(approver_key, message)

The registry (approvers.json) lists {id, name, public_key, weight}. Weight is summed over distinct approvers whose signatures verify. A reject from any registered approver denies the intent permanently — in the control plane and, independently, in each signer. The initiator may reject their own intent and may not approve it.

uny approver keygen creates a key and prints its registry entry; uny approver sign produces the JSON for POST /intent/{id}/approve-signed. Approver keys should live on the approver's own machine, never on the control plane.

Policy digest and pinning

policy_digest = sha256_domain("unykorn.vault.policy.v1", canonical_json(policy_set)). The control plane sends the set and its digest to each signer; the signer recomputes. A signer may pin digests per wallet (pinned_policies in its config) so a control plane that swaps a policy set is refused even if the set it sends is internally consistent.

Applying a policy

uny wallet apply-policy --state runtime/api/state.json --signer-seed runtime/api/ledger.seed --policy policy.json
curl -X POST 127.0.0.1:7331/admin/reload -H "authorization: Bearer $TOK"

version must increase. The application is receipted (policy_applied).

Example — an issuing treasury on XRPL testnet:

{"wallet":"wal_treasury-xrp","version":3,"rules":[
  {"rule":"approval_threshold","asset":{"chain":"xrpl-testnet","symbol":"XRP","decimals":6,"issuer":null},"above":"0","required_weight":1},
  {"rule":"hard_ceiling","asset":{"chain":"xrpl-testnet","symbol":"XRP","decimals":6,"issuer":null},"max":"50000000"},
  {"rule":"allow_operations","operations":["issue","authorize_holder","clawback","mpt_create","account_flags"],"required_weight":1}
]}

Chain names in policy files are the serde names (xrpl-testnet, stellar-testnet, evm-local, ethereum-mainnet…), not the short ids used in URLs (xrp-testnet).

Keys

Keys

Where keys live

Holder Key Backend Notes
signer-a K1 (one key per curve: ed25519 for XRPL/Stellar, secp256k1 for EVM) software keystore or YubiHSM 2 weight 1 on every wallet
signer-b K3 policy co-signer software keystore or YubiHSM 2 weight 2; mandatory by weight arithmetic
approver machines approver keys (ed25519) software keystore never on the control plane
control plane none for wallets; a gas-only EVM relayer key software keystore pays execTransaction; refused if 7702-delegated; can never be a Safe owner
custodians (offline) recovery shares printed cards (uny recovery rehearse) reconstructed only in a rehearsal or a real recovery

Software keystore (uny-keys::SoftwareKeystore)

File format (keys.json): a header (version, salt, Argon2id parameters — 64 MiB, 3 passes, 1 lane) and, per key, an XChaCha20-Poly1305 sealed secret with the header bytes as associated data, so a modified header fails to decrypt. Public parts (id, label, curve, public_key, scope, created_at) are readable without the passphrase. Secrets are zeroized after use.

The passphrase comes only from UNY_KEYSTORE_PASSPHRASE (CLI) or UNY_SIGNER_PASSPHRASE (signer). No command accepts it as an argument.

uny keys init  --keystore keys.json
uny keys gen   --keystore keys.json --id k1 --curve ed25519 --label "K1 ops"
uny keys gen   --keystore keys.json --id evm-k1 --curve secp256k1
uny keys list  --keystore keys.json          # ids, curves, XRPL/Stellar/EVM addresses, scope

Key scope — the rule that keeps a laptop key from moving real money

{"scope":"testnet_only"}
{"scope":"mainnet","asset_key":"xrp:XRP","cap_minor_units":"100000000"}

Every software key is testnet_only unless created with all three of:

uny keys phrase                                  # prints the exact sentence
uny keys gen --keystore keys.json --id k1-main --curve ed25519 \
  --mainnet-asset xrp:XRP --cap 100000000 \
  --confirm "I understand this key lives on this computer and can move real funds"

sign_scoped refuses: a mainnet payload from a testnet_only key; a mainnet payload in a different asset from asset_key; a mainnet payload above cap_minor_units. The check is in the signer, keyed off the intent's chain (Chain::is_testnet) and asset.

Recommendation carried in CUSTODY.md: mainnet keys live in the HSM, not in software.

YubiHSM 2 backend (uny-keys::hsm)

Signer config: "backend":{"kind":"yubihsm","config":"hsm.json"}, where hsm.json is an HsmConfig:

{"connector_addr":"127.0.0.1","connector_port":12345,"auth_key_id":1,"domain":1,
 "keys":{"k1":{"object_id":100,"curve":"ed25519","label":"K1 ops","mainnet":false}}}

Password from UNY_HSM_PASSWORD. Keys are generated non-exportable inside the device (uny-keys::hsm::YubiHsmBackend::generate); the signer only ever sees signatures. Exercised against the crate's mock HSM (--features mockhsm); plugging in real hardware is a configuration change. State: GATED on hardware.

Key scope on an HSM key is mainnet: bool at generation time; caps are enforced by policy (hard_ceiling) rather than by the device.

Approver keys

uny approver keygen --keystore approver.json --id counsel --weight 1 --registry approvers.json
# → appends the registry entry to approvers.json (created if missing) and prints it
uny approver sign   --keystore approver.json --id counsel --intent int_… [--reject] [--strong-auth]
# → prints the SignedApproval JSON for POST /intent/{id}/approve-signed

The registry (approvers.json) is read by the control plane (UNY_APPROVERS) for weight and by each signer (approvers in its config) for verification. They should be the same file distributed out-of-band; a signer with a stale registry refuses approvals from approvers it does not know, which is the safe direction.

Recovery

No wallet is created without a ProvenRecoveryPlan: uny native create shards the recovery secret (hybrid post-quantum encryption to each custodian, uny-pq), reconstructs it from a threshold of real shares, confirms one share short fails, and only then writes the wallet. The proof digest is on the wallet record and in GET /recovery/status.

Ledger accounts and their controls

Ledger Control Set up by
XRPL SignerListSet (K1×1, K3×2, quorum 3); issuer flags; master disabled after rehearsal uny native setup-xrpl
Stellar signers with weights, thresholds 3/3/3, master weight 0, issuer flags — in one SetOptions uny native setup-stellar
EVM Safe v1.4.1 via the canonical SafeProxyFactory, owners K1 + K3, threshold 2 uny native deploy-safe

The descriptor (uny native describe) records exactly this and is what every signer validates against; GET /wallets/{id}/reconcile checks the ledger still agrees.

CLI

uny command reference

Key material is never accepted as a command-line argument. Passphrases come from UNY_KEYSTORE_PASSPHRASE; the operator passphrase from UNY_OPERATOR_PASSPHRASE (used by the run/ scripts, not by uny itself).

Chain ids on the command line are the short ids: xrp, xrp-testnet, xlm, xlm-testnet, eth, base, arb, matic, avax, op, eth-sepolia, base-sepolia, arb-sepolia, matic-amoy, avax-fuji, evm-local.

keys — encrypted keystore

command options does
keys init --keystore create an empty Argon2id/XChaCha20 keystore
keys gen --keystore --id --curve ed25519\|secp256k1 [--label] [--mainnet-asset --cap --confirm] generate a key; testnet-only unless all three mainnet options are given
keys list --keystore ids, curves, XRPL / Stellar / EVM addresses, scope
keys phrase print the exact mainnet confirmation sentence

approver — signed approvals

command options does
approver keygen --keystore --id [--weight 1] --registry generate an approver key and append its entry to the registry file
approver sign --keystore --id --intent [--reject] [--strong-auth] print a SignedApproval JSON for POST /intent/{id}/approve-signed

native — native-multisig and Safe wallets, ceremonies, holders

command options does
native describe --wallet --chain --address --signer ADDRESS:CURVE:WEIGHT:LABEL… --quorum print and validate a WalletDescriptor
native create --state --signer-seed --wallet --label --descriptor [--threshold 2] --custodians --secret-file --degradation --cards-out register the wallet after a real recovery rehearsal (cards written to --cards-out)
native fund --keystore --id --chain xrp-testnet\|xlm-testnet faucet / friendbot
native setup-xrpl --keystore --master --chain --signer ADDRESS:WEIGHT… --quorum [--issuer] [--disable-master] [--rehearse-with ADDRESS=KEYID…] [--domain] [--rpc] account flags in the safe order, SignerListSet, multisigned rehearsal, master off
native setup-stellar --keystore --master --chain --signer ADDRESS:WEIGHT… [--thresholds l,m,h] [--issuer] [--disable-master] [--home-domain] [--horizon] one SetOptions: flags, signers, thresholds, master weight
native deploy-safe --keystore --relayer --chain --owner… --threshold [--salt 7777] [--rpc] [--l1-singleton] deploy a Safe v1.4.1 through the canonical factory
native holder --keystore --key --chain --asset [--limit] [--rpc] holder opt-in: XRPL TrustSet / MPTokenAuthorize, Stellar ChangeTrust

wallet — registry and policy

command options does
wallet create --state --signer-seed --wallet --label --chain --enterprise-id [--threshold] --custodians --secret-file --degradation --cards-out custodian-backed wallet with rehearsal (BitGo path)
wallet apply-policy --state --signer-seed --policy apply a PolicySet JSON (version must increase); follow with POST /admin/reload

policy — offline evaluation

command options does
policy eval --policy --intent [--approvals] [--velocity] [--now] [--compliance-cleared] [--attestation-valid] evaluate an intent JSON against a policy JSON and print the decision

recovery, ledger, pq

command options does
recovery keygen --secret-out hybrid post-quantum recipient key for one custodian
recovery rehearse --wallet --threshold --custodians [--secret-file] --degradation --out rehearse a plan against a real secret; emit proven cards
ledger verify --file [--trusted pk,pk] verify a receipt chain end to end, optionally checking provenance
ledger anchor --file --from --to Merkle anchor over a sequence range
pq keygen --seed-out receipt-signing seed for a service

Typical sequences

Issuer on XRPL testnet, from nothing:

export UNY_KEYSTORE_PASSPHRASE='…'
uny keys init --keystore api/keys.json
uny keys gen  --keystore api/keys.json --id xrpl-master --curve ed25519
uny native fund --keystore api/keys.json --id xrpl-master --chain xrp-testnet
uny native setup-xrpl --keystore api/keys.json --master xrpl-master --chain xrp-testnet \
  --signer rK1…:1 --signer rK3…:2 --quorum 3 --issuer --domain unykorn.ai
uny native describe --wallet treasury-xrp --chain xrp-testnet --address rISSUER \
  --signer "rK1…:ed25519:1:K1 ops" --signer "rK3…:ed25519:2:K3 policy" --quorum 3 > treasury-xrp.descriptor.json
uny native create --state api/state.json --signer-seed api/ledger.seed --wallet treasury-xrp --label "Treasury" \
  --descriptor treasury-xrp.descriptor.json --custodians custodians.json --secret-file recovery.secret \
  --degradation "…" --cards-out cards
uny wallet apply-policy --state api/state.json --signer-seed api/ledger.seed --policy policy.json

run/bootstrap-testnet.ps1 is exactly this for both testnets.

A client opting in to your asset:

uny native holder --keystore client.json --key holder --chain xrp-testnet --asset rISSUER.UNYD --limit 1000000
uny native holder --keystore client.json --key holder --chain xrp-testnet --asset <48-hex MPT id>
uny native holder --keystore client.json --key holder --chain xlm-testnet --asset UNYD:GISSUER

Runbook

Runbook — a fresh machine to a working, issuing vault

0. Build

cargo build --release

Produces uny, uny-api, uny-signer in target/release. Windows and Linux. cargo test --workspace — 300+ tests, including byte-identity vectors against the reference libraries; nothing here should be run on a machine where they fail.

1. Bootstrap (once)

$env:UNY_KEYSTORE_PASSPHRASE = "<long passphrase — write it down, it protects every software key>"
.\run\bootstrap-testnet.ps1

Creates runtime\ with:

runtime/
  approvers.json               kevan (1) + counsel (1), public keys only
  signer-a/  keys.json k1      signer.json  ledger.seed  receipts.json  signer.log
  signer-b/  keys.json k3      signer.json  ledger.seed  receipts.json  signer.log
  api/       keys.json         xrpl-master, stellar-master (ceremony only), approver keys, relayer
             state.json        wallets, policies, intents, velocity, receipts
             ledger.seed       control-plane receipt-signing seed
             signers.json      the two signer endpoints and their addresses
             burned.json       attacker addresses the guards refuse
             policy.treasury-xrp.json / policy.treasury-xlm.json   (allow_operations included)
             treasury-*.descriptor.json
             cards-xrp/ cards-xlm/   printed recovery cards — move these off the machine
             custodians.json recovery.secret c1..3.json          — ceremony inputs; delete after cards are safe

It funds the testnet accounts, runs setup-xrpl --issuer and setup-stellar --issuer --disable-master, registers both wallets with a rehearsed recovery plan, and applies policies that already permit issue, authorize_holder, clawback, mpt_create and account_flags with approval weight 1.

runtime/, keystores, seeds and state.json are git-ignored. They are never committed.

2. Start

$env:UNY_KEYSTORE_PASSPHRASE = "<same>"
$env:UNY_OPERATOR_PASSPHRASE = "<operator unlock passphrase>"
.\run\start.ps1

Two signers (7333, 7334) and the control plane (7331), loopback only. Signer tokens are generated per start and live in the shell that started them. .\run\stop.ps1 stops all three. On Linux/macOS: run/start.sh.

Health: curl 127.0.0.1:7331/health, …:7333/health, …:7334/health — all three must say their receipt chain verified.

3. Move value

.\run\move.ps1 -Wallet wal_treasury-xrp -Destination rDEST -Amount 1500000

intent → counsel's signed approval (the script signs with the counsel key in api\keys.json; in production that key is on counsel's own machine) → execute → hash.

4. Issue a stablecoin

# client side, once: the holder opts in (their key, their machine)
uny native holder --keystore client.json --key holder --chain xrp-testnet --asset rISSUER.UNYD --limit 1000000

# issuer side
.\run\move.ps1 -Wallet wal_treasury-xrp -Destination rHOLDER -Amount 0          -Symbol UNYD -Issuer rISSUER.UNYD -Operation '{"op":"authorize_holder"}'
.\run\move.ps1 -Wallet wal_treasury-xrp -Destination rHOLDER -Amount 1000000000 -Symbol UNYD -Issuer rISSUER.UNYD -Operation '{"op":"issue"}'
.\run\move.ps1 -Wallet wal_treasury-xrp -Destination rHOLDER -Amount 250000000  -Symbol UNYD -Issuer rISSUER.UNYD -Operation '{"op":"clawback"}'

# an MPT
.\run\move.ps1 -Wallet wal_treasury-xrp -Destination rISSUER -Amount 0 -Operation '{"op":"mpt_create","asset_scale":6,"maximum_amount":1000000000000,"flags":100}'
#   → mptIssuanceId in the output; the holder runs `uny native holder --asset <id>`; then authorize_holder + issue with -Issuer <id>

Full worked transcript on the public testnets: docs/e2e-2026-09-12/ISSUANCE-TRANSCRIPT.md.

5. Check the estate and the record

GET /estate                       balances, canonical flag per asset
GET /wallets/{id}/reconcile       control + history against the live ledger
GET /ledger/verify                control-plane chain
GET :7333/receipts                signer-a chain    GET :7334/receipts    signer-b chain

Environment variables

uny-api

var default meaning
UNY_BIND 127.0.0.1:7331 must be loopback unless UNY_ALLOW_PUBLIC_BIND=i-understand
UNY_STATE state.json path
UNY_SIGNER_SEED receipt-signing seed
UNY_PASSPHRASE_SHA256 SHA-256 hex of the operator passphrase
UNY_SESSION_TTL service default session token lifetime, seconds
UNY_ENTERPRISE unykorn-llc enterprise id on receipts
UNY_SIGNERS signers.json: [{"id","url","token_env","signer_addresses":[…]}]
SIGNER_A_TOKEN, … whatever token_env names, one per signer
UNY_APPROVERS approvers.json
UNY_BURNED JSON array of attacker addresses
UNY_RPC_<CHAIN> public endpoints override per chain: id upper-cased, -_ (UNY_RPC_XRP_TESTNET, UNY_RPC_EVM_LOCAL)
UNY_RELAYER_KEYSTORE, UNY_RELAYER_PASSPHRASE, UNY_RELAYER_KEY gas-only key for Safe execTransaction

uny-signer

var meaning
UNY_SIGNER_CONFIG config JSON (see API.md)
UNY_SIGNER_TOKEN ≥ 24 chars; the control plane sends it in x-uny-signer-token
UNY_SIGNER_PASSPHRASE software keystore passphrase
UNY_HSM_PASSWORD YubiHSM auth key password
UNY_ALLOW_PUBLIC_BIND i-understand to bind off loopback (never in shipped configs)

uny (CLI)

var meaning
UNY_KEYSTORE_PASSPHRASE the only way a passphrase reaches the CLI

Public RPC caveats

The XRPL Labs testnet node rate-limits bursts and answers with a non-JSON body; the client treats that as "not validated" and retries with backoff (8 tries, 4 s × n). For production run your own rippled / Horizon / EVM node or a paid endpoint and set UNY_RPC_<CHAIN>.

Going to mainnet — the gate, in order

  1. Order and install the YubiHSM 2 units; generate mainnet keys inside them (mainnet: true in HsmConfig); switch each signer to backend.kind = "yubihsm".
  2. Re-run the XRPL / Stellar ceremonies on mainnet with --issuer where the account issues, then account_flags DisableMaster (XRPL asf 4) through the pipeline once the multisigned rehearsal validated.
  3. Policies with real ceilings, allowlists and allow_operations weights ≥ 2.
  4. Approver keys on the approvers' own hardware.
  5. Pin policy digests in each signer.
  6. Point UNY_RPC_* at your own nodes.

Nothing technical prevents mainnet before step 1; a software key created with the typed phrase and a cap can sign it. The recommendation is not to.

Integration

Integration and extension guide

How to add to the system without weakening it. Each section names the files, the trait or enum to extend, the test that must pass, and the invariant that must survive.

Workspace map

crates/
  uny-types          Chain, AssetSpec, Money, TransferIntent, Operation, ids, canonical JSON
  uny-chains         per-ledger codecs, builders, RPC clients, canonical asset registry, protections, build::prepare
  uny-keys           software keystore, YubiHSM backend, key scope, signed approvals
  uny-policy         rule catalogue, PolicySet::evaluate, velocity ledger
  uny-signer-core    the signer's decision sequence
  uny-store          state.json: wallets, policies, intents, receipts; ProvenRecoveryPlan gate
  uny-receipts       hash-chained signed receipts (ML-DSA co-signed via uny-pq)
  uny-recovery       recovery plan rehearsal, custodian cards
  uny-issuance / uny-issuance-flow   instrument register, supply ceilings, attestations
  uny-bitgo          the chartered-custodian backend (BitGo) — separate path, untouched by this layer
services/
  uny-api            control plane (axum)
  uny-signer         signer service (axum)
  uny-cli            `uny`
desktop/             Electron shell
site/                unykorn.ai and custody.unykorn.ai
run/                 bootstrap / start / move / stop scripts

Add a chain

  1. crates/uny-types/src/chain.rs — add the variant to Chain, Chain::ALL, id(), kind(), is_testnet() / mainnet_of(), native_symbol(), decimals(), and evm_chain_id() if EVM. Serde name is kebab-case of the variant.
  2. If it is an EVM chain, that is all the chain layer needs: evm::rpc::default_endpoint gets an entry, and Safe deployment works wherever the canonical 1.4.1 factory exists.
  3. If it is a new ledger family, add crates/uny-chains/src/<ledger>/ with: address codec, transaction builder producing a Prepared::<Ledger> variant with a domain-separated digest, payload_for (what one signer signs), assemble (signatures → broadcastable bytes), an RPC client, and a ChainParams::<Ledger> variant. Wire the arm in build::prepare for every Operation (refuse what the ledger cannot do), in uny-api::exec (params fetch, submit, confirm, reconcile), and in uny-cli::chains_cmd (setup ceremony, holder opt-in).
  4. Vectors first. Add tests/vectors/<ledger>.json generated by the ledger's own reference library (gen_*.js shows the pattern) and a tests/<ledger>_vectors.rs that asserts byte identity. Nothing ships on a ledger whose encoding is not checked against its reference implementation.
  5. Add a native fund path if a faucet exists, and a bootstrap step in run/bootstrap-testnet.ps1.

Invariant to preserve: prepare must be a pure function of (intent, wallet, params). No clock, no RNG, no network inside it.

Add a canonical asset

crates/uny-chains/src/assets.rs, REGISTRY:

asset!("EURC", "Circle", Base, "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", None, 6, CIRCLE_EURC, Verified),

source is the issuer's own published list (a const URL); Verified means the address came from that page, Unverified means a third-party list (policy must opt in, the guard warns). If the ticker is new and should be protected, add it to RESERVED_TICKERS. The counterfeit guard then refuses that ticker from any other issuer on that chain — including Cyrillic look-alikes, which fold_symbol normalises.

Test: assets::tests round-trips every entry through lookup and by_address.

Add a signer backend

Implement uny_keys::KeyBackend:

pub trait KeyBackend: Send + Sync {
    fn backend_name(&self) -> &'static str;
    fn public_key(&self, key_id: &str) -> Result<PublicKeyBytes>;
    fn sign(&self, key_id: &str, payload: &SigningPayload) -> Result<CurveSignature>;
    fn list(&self) -> Result<Vec<String>>;
}

sign must enforce scope itself (see SoftwareKeystore::sign_scoped and YubiHsmBackend::sign_scoped): the backend, not the caller, decides whether a mainnet payload is permitted. Add a Backend::<Name> arm in uny-signer-core and a BackendConfig::<Name> in services/uny-signer/src/main.rs. Secrets come from an environment variable, never from the config file.

The custodian proxy (North Capital / Zero Hash) is this: a KeyBackend whose sign is an API call to the custodian, with the custodian's own policy as a second gate. It is THIN until credentials exist.

Add an operation

  1. crates/uny-types/src/operation.rs — add the variant; decide carries_value and allows_zero_amount; give it a stable name().
  2. crates/uny-chains/src/build.rs — map it on every ledger arm, or return ChainError::Unsupported with a message that names the right operation to use.
  3. Tests in build::tests for the happy path and the refusal.
  4. Because it is not transfer, the structural rule already refuses it unless a policy names it. Document it in OPERATIONS.md and POLICY.md.

Never add an operation that signs caller-supplied bytes without a builder. contract_call is the only free-form one, it is EVM-only, and it is gated by allow_operations.

Add a policy rule

crates/uny-policy/src/rules.rs: add the variant (serde snake_case, tagged rule), implement it in Rule::evaluate returning Pass, Deny, Delay or NeedsApprovals. A rule sees the intent and the EvalContext (now, approvals, velocity ledger, compliance and attestation flags, agent budget) and nothing else — it cannot read other rules or the previous decision. Add a test in rules::tests. Rules run in every signer too, automatically.

Add a protection guard

crates/uny-chains/src/protect.rs: a fn <guard>(intent, ctx, out: &mut Vec<Finding>) called from evaluate. Finding { guard, severity: Block|Warn, detail }. Inputs you need from the chain go into Context and are fetched in uny-api::exec (protect_preview and execute), then shipped to signers in ProtectInputs, so the signer's run has the same facts. blocked() is true if any finding is Block.

Drive it from another system

  • HTTPAPI.md. Create intents from your back office, have approvers sign with uny approver sign on their own machines, execute. Poll GET /intent/{id}.
  • ReceiptsGET /ledger/recent and each signer's GET /receipts. Verify with the public key offline: the chain format is uny-receipts.
  • ReconciliationGET /wallets/{id}/reconcile per wallet on a schedule; clean:false or any critical drift is a page.
  • EstateGET /estate for balances by wallet, with canonical per asset.
  • Desktop / extension — the extension talks only to 127.0.0.1:7331 through /snapshot, /pair/* and /intent; it holds no key and cannot approve.

What not to do

  • Do not add a route that signs. Signing is uny-signer's job and it only signs what it rebuilt.
  • Do not put a wallet key on the control plane. The relayer key is gas-only and is checked for a 7702 delegation before every use.
  • Do not let a policy be applied over HTTP. apply-policy is a CLI ceremony with a receipt; /admin/reload only re-reads disk.
  • Do not accept a passphrase as an argument anywhere.
  • Do not publish a figure on the site that does not resolve to a transcript or a test; site/custody/verify.mjs will fail the build.