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 againstripple-binary-codec,@stellar/stellar-sdkandethers.
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.
- Intent integrity —
intent.verify_id(): the content address must match the body. - Policy integrity —
policy_digest(policy)must equalpolicy_digestin the request; if the signer pins a digest for this wallet, it must equal the pin. - Approvals — every
SignedApprovalis verified against the signer's own registry. Weight is summed only from approvals that verify. Any verified rejection stops here. - Policy — the wallet's policy set is evaluated by the signer with the weight it
verified itself. Not
Allow→ refuse. - Protections —
uny_chains::protect::evaluatewith the control plane's inputs and the signer's own burned list merged in. AnyBlockfinding → refuse. - Rebuild —
uny_chains::build::prepare(intent, wallet, params); its digest must equalexpected_payload_digest. - Signer identity —
signer_addressmust be one of the wallet's signers, and the signer must hold a key for it. - Key scope — testnet-only keys refuse mainnet payloads; mainnet keys refuse a different asset or an amount above their cap.
- Sign, then self-verify the signature against the payload.
- Receipt —
policy_evaluatedandsignature_producedon 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.
tfPartialPaymentis never set. AUTH_IMMUTABLEon Stellar. Permanent; refused.- Admin roles on paired devices.
approver,initiator,vieweronly.
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 memo → DestinationTag |
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_holder → issue,
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":"…"}
status ∈ allowed · 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)
- Empty policy set = deny. A wallet with no rules refuses every movement.
- Unnamed operation = deny. An intent whose
operationis nottransferis refused unless anallow_operationsrule in the set names it — before any other rule runs. - 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
- Order and install the YubiHSM 2 units; generate mainnet keys inside them
(
mainnet: trueinHsmConfig); switch each signer tobackend.kind = "yubihsm". - Re-run the XRPL / Stellar ceremonies on mainnet with
--issuerwhere the account issues, thenaccount_flagsDisableMaster(XRPL asf 4) through the pipeline once the multisigned rehearsal validated. - Policies with real ceilings, allowlists and
allow_operationsweights ≥ 2. - Approver keys on the approvers' own hardware.
- Pin policy digests in each signer.
- 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
crates/uny-types/src/chain.rs— add the variant toChain,Chain::ALL,id(),kind(),is_testnet()/mainnet_of(),native_symbol(),decimals(), andevm_chain_id()if EVM. Serde name is kebab-case of the variant.- If it is an EVM chain, that is all the chain layer needs:
evm::rpc::default_endpointgets an entry, and Safe deployment works wherever the canonical 1.4.1 factory exists. - If it is a new ledger family, add
crates/uny-chains/src/<ledger>/with: address codec, transaction builder producing aPrepared::<Ledger>variant with a domain-separated digest,payload_for(what one signer signs),assemble(signatures → broadcastable bytes), an RPC client, and aChainParams::<Ledger>variant. Wire the arm inbuild::preparefor everyOperation(refuse what the ledger cannot do), inuny-api::exec(params fetch, submit, confirm, reconcile), and inuny-cli::chains_cmd(setup ceremony, holder opt-in). - Vectors first. Add
tests/vectors/<ledger>.jsongenerated by the ledger's own reference library (gen_*.jsshows the pattern) and atests/<ledger>_vectors.rsthat asserts byte identity. Nothing ships on a ledger whose encoding is not checked against its reference implementation. - Add a
native fundpath if a faucet exists, and a bootstrap step inrun/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
crates/uny-types/src/operation.rs— add the variant; decidecarries_valueandallows_zero_amount; give it a stablename().crates/uny-chains/src/build.rs— map it on every ledger arm, or returnChainError::Unsupportedwith a message that names the right operation to use.- Tests in
build::testsfor the happy path and the refusal. - Because it is not
transfer, the structural rule already refuses it unless a policy names it. Document it inOPERATIONS.mdandPOLICY.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
- HTTP — API.md. Create intents from your back office, have approvers
sign with
uny approver signon their own machines, execute. PollGET /intent/{id}. - Receipts —
GET /ledger/recentand each signer'sGET /receipts. Verify with the public key offline: the chain format isuny-receipts. - Reconciliation —
GET /wallets/{id}/reconcileper wallet on a schedule;clean:falseor anycriticaldrift is a page. - Estate —
GET /estatefor balances by wallet, withcanonicalper asset. - Desktop / extension — the extension talks only to
127.0.0.1:7331through/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-policyis a CLI ceremony with a receipt;/admin/reloadonly 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.mjswill fail the build.