A wallet-side rail for licensed issuers of real-world assets.
IssuerAuthority::LicensedIssuer, the only
constructor that admits a chartered bank or a permitted stablecoin
issuer. We add a namespace to EIP-1193 for what custody actually does
(multi-approver movements, timelocks, policy refusals, recovery
preconditions) and an agent-safe capability layer (x402)
that neither BitGo nor MetaMask has. This paper explains the shape,
the guardrails, and the parts that are deliberately not built.
01 Executive summary
Three positions define the system:
- The operator is never the issuer.
Instrument::new()refuses any name resolving to Unykorn. The client brings the licence and is the issuer of record. - Every negative state is representable. Standard EIP-1193 can say YES or NO. Real custody needs to say refused for reason X, needs two more approvers, timelocked until T, recovery not proven. We add those four with error codes in the 4900 application-defined range.
- Agents get their own primitive. The x402 capability token is a narrow, expiring, revocable, budget-bounded grant. It can never sign, never approve, and cannot raise its own budget. Fully compromised, its blast radius is remaining budget → allowlist.
What ships: real policy engine, recovery ceremony
with negative-control proof, x402 tokens, EIP-1193 + EIP-6963 provider, the
issuance-register legal types, an offline-verifiable ML-DSA-65 receipt chain,
and — as of fbacc68 — issuance gated by the same policy engine
that governs movements, with three receipts per action and a register-versus-
supply reconciliation gate that refuses commits which break the invariant.
What does not: gated custodian submission
(allowed means policy passed, not moved), live-attestation feed,
threshold signing backends. What is deliberately absent:
thin any smart contract in this repo — the
on-chain leg is client-deployed under the client's own licence.
271 tests pass (235 Rust + 17 x402 + 19 vault-sdk). make ci
is green for the first time as of fbacc68 — pre-existing clippy
failures in uny-recovery, uny-bitgo, uny-store,
uny-pq, and uny-api were cleaned up in the same patch.
02 The problem with custody-blind wallets
A wallet built for a person clicking Confirm can only say two things about any request: you signed it or you didn't. That model has no vocabulary for the four things a custody platform does constantly.
- A movement can be valid but need a second approver.
- A movement can be approved but pinned to a timelock.
- A movement can be refused for a reason the operator can't override in one click.
- A wallet can exist but not be safe to receive funds because its recovery hasn't been rehearsed.
Every one of those is not user rejection. Every one of those, an EIP-1193 wallet must currently return as an error. Every dapp receiving that error teaches its user to retry, and every retry is wrong — the retry runs into the same policy the previous attempt did.
The industry's response has been to hide policy behind institutional middleware — BitGo Console, Fireblocks, Copper. That works for the operator UI but not for dapps: a dapp still sees only the standard provider, so agentic treasury tooling either doesn't exist or lives outside the wallet's guardrails entirely.
UNYKORN Vault adds the missing vocabulary at the provider layer, so a dapp can render "waiting on 1 more approver" or "unlocks in 24h" directly, without the operator needing to open a second surface.
03 The rail vs the issuer
Every dollar-denominated instrument on this platform lands through exactly one constructor:
/// Issued by a chartered bank or a permitted payment-stablecoin issuer under
/// its own licence. **This is the only route by which a dollar-denominated
/// instrument exists on this platform**, and the licence belongs to the
/// issuer, never to the operator.
LicensedIssuer { institution: String, licence: String },
And every attempted Instrument::new() whose issuer
resolves to the platform operator is rejected at compile time:
const OPERATOR_NAMES: &[&str] = &[
"unykorn", "uny korn", "unykorn llc", "unykorn 7777",
];
// "The operator builds and runs this infrastructure; it does not issue on it."
This is not a policy statement. It is a compilation-refusing constant. An
engineer who tries to remove it discovers that Instrument::new()
is the only public constructor and the check runs unconditionally. The
alternative — a platform that could quietly become an issuer if someone
changed a config — is not one this codebase can be reconfigured into.
The BitGo→SoFi shape: BitGo sells the qualified-custody rail; SoFi and its peers use it to hold their customers' assets. BitGo never touches the customer relationship, the licence, or the balance-sheet liability. That shape survives regulatory diligence in a way "we run a stablecoin platform" does not.
04 The three planes
The policy engine evaluates independently on three planes:
| Plane | Location | Purpose |
|---|---|---|
| Control | uny-api on 127.0.0.1:7331 | The operator UI's backend. Loopback-only; a non-loopback bind is refused, not merely discouraged. |
| Signer | Extension service worker | Where the wallet key is held. Re-evaluates policy from its own state; does not trust the control plane's decision. |
| Verification | Anywhere with the signer's public key | Anyone can pull a copy of the receipt chain and verify it independently. |
The engine itself is pure: no I/O, no clock reads, no database.
That's what lets the same evaluation run three different places and
arrive at the same answer. Injecting a repository into it — a common
"convenience" refactor — destroys the security property. It is one of
ten rules in BUILD-PROMPT.md
that the CI's posture job enforces.
05 Recovery is a precondition, not a procedure
create_wallet() takes a ProvenRecoveryPlan,
which has no public constructor, no Default, and no
Deserialize. The only path that produces one is a
rehearsal that:
- Reconstructs the wallet key from
tofnshares. - Proves that
t-1shares FAILS to reconstruct.
A ceremony that only proves reconstruction succeeds has not proven the
threshold. If the negative control were ever silently dropped — a
builder in a hurry, a test helper that shortcuts to a valid
ProvenRecoveryPlan — the wallet would be created against
a threshold nobody has actually rehearsed. The consequence would be
first-noticed on the day it mattered, which is the day you cannot
recover from.
Printable custodian cards are produced as part of the ceremony. Each card records the wallet id, custodian name, share index (1 of 3), the rehearsal timestamp, and a secret digest that can be re-verified out-of-band before unsealing.
06 The x402 capability layer
Neither BitGo nor MetaMask answers the question an agentic treasury actually asks: how do I let a machine spend, continuously, without giving it the keys or a human babysitter?
BitGo's answer is API keys — which are broad, hard to revoke fast, and have no ambient budget bounds beyond what the operator remembers to set. MetaMask's answer is you don't. Neither shape is what a payables agent, a payroll bot, or an inventory-triggered rebalancer needs to be safe.
x402 is a signed envelope of the shape:
{
header: { typ: "uny-cap", v: 1, iss: "<issuer pubkey hex>" },
claims: { sub, enterprise, wallet, asset,
budget, perTxCeiling, destinations,
notBefore, expiresAt, nonce },
signature: "<Ed25519 over SHA-256(domain || canonical(header) || canonical(claims))>",
}
Domain separator: unykorn.vault.capability.v1. One
algorithm. One domain. No JWT. Algorithm agility is the
vulnerability — alg:none and RS256/HS256 confusion have
each produced real custody incidents. There is no negotiation
surface.
Guardrails:
- A capability can only create intents. It cannot sign, cannot approve, cannot raise its own budget.
- Every check is a refusal, not a clamp.
REFUSE, DO NOT CLAMP— an over-budget request is rejected outright rather than silently reduced. A clamped payment is a wrong payment and the agent has no way to know it happened. - Spend is tracked against the capability's
nonce, not its subject, so re-minting a capability for the same agent starts a fresh budget while the old one stays independently revocable. - Compromise blast radius is: remaining budget → destinations already on the allowlist. Every unit spent lands on the receipt chain.
07 The issuance register and the compliance engine
Even though no instrument is issued by the operator, the operator holds the register — the record of who owns how much, and under what basis. This is what a transfer agent and a broker-dealer back office actually run on.
The register is typed:
| IssuerAuthority | What it admits |
|---|---|
RegD506b | Private placement, up to 35 non-accredited, no general solicitation. |
RegD506c | Private placement with general solicitation, accredited-only, verified. |
RegS | Offshore-only. |
RegA | Mini-IPO, up to $75M/year, accredited + retail. |
Registered | SEC-registered (S-1 / F-1 / etc.). |
LicensedIssuer | The dollar door. Chartered bank or permitted stablecoin issuer under its own licence. |
NotASecurity | Counsel-memo-gated. Refuses to construct without a memo hash on file. |
Settlement is typed:
| SettlementVenue | What it means |
|---|---|
BookEntry | Registrar of record, nothing touches a chain. This is what a transfer agent runs on — holders don't transfer, the intermediary does. Register authoritative; chain optional. |
AlternativeTradingSystem | ATS-based venue-of-record for the trade; register still authoritative. |
OnChainPermissioned | ERC-3643 shaped. contract field is Option<String> — defaults to None because no smart contract is deployed by this repo. The chain leg is client-chosen and client-deployed. |
OmnibusCustody | Positions held omnibus by a custodian; the register maps beneficial owners under a single wallet. |
Compliance is enforced per rule:
| ComplianceRule | Enforcement |
|---|---|
verified_recipient | Recipient identity must be verified on file before receipt. |
jurisdiction_allow / block | Country / state / OFAC-level allow-list or block-list. |
holding_period | Reg D / Reg S resale restrictions. |
max_holders_of_record | Section 12(g) 2,000-holder ceiling / 500 non-accredited ceiling. |
max_position_per_holder | Beneficial-owner-level concentration limits. |
agent_only_transfers | The broker-dealer back-office primitive. Holders cannot transfer; the intermediary transfers on their behalf. |
attestation_ceiling | Supply hard-capped at the median of a signed verifier quorum. Recomputed per mint, never cached. Staleness halts minting automatically. |
08 What is deliberately not built
A component labelledrealis running and verified. A component labelledgatedis built to a boundary that isn't wired past. A component labelledthinis a surface that exists so the UI reads honestly. None of the three claims audit status; only 7 of 271 tests verify external truth, and the rest verify internal consistency. An internal adversarial review dated 2026-08-08 named 1 critical, 5 high, 6 medium, 4 low and set two operational restrictions (no recovery ceremony for value-bearing wallets; loopback-only control plane exposure). External security review from a named firm is required before any wallet holds client money — seeSECURITY-REVIEW-2026-08-08.md.
The current gated boundaries:
Custodian submission—allowedcurrently means policy passed. Wiring step 7 ofcreate_intentto BitGo Express (against the TEST environment first) is the next milestone. Until then,allowedis a decision, not a settlement.Threshold signing (Tss) / HSM backends—BitGoProxyis the production key path. Local Tss is a placeholder trait so the wallet types compile against multiple backends.Live balance feed → attestation ceiling— the type is real and tested; nothing pushes a real reserve balance into it. When wired, staleness halts minting automatically./admin/reload endpoint— provisioning currently requires a service restart. The reload path is documented and will re-verify the receipt chain on reload, refusing to swap in state that fails verification.
The current thin surfaces:
- Smart contracts on any chain — zero
.sol/ on-chain deploys in this repo, by design. The chain leg is client-chosen and client-deployed under the client's own licence. - Card · Cover · Mesh — extension tabs render
standby. Nothing behind them yet. Honest UI is not a bug. - vault-sdk instrument surface — vault-sdk exposes wallets and movements. It does not yet expose
uny_listInstrumentsoruny_transferVerdict.
Recently promoted to REAL. The issuance ↔ policy pipe was thin at v1.0 and landed asuny-issuance-flowin commitfbacc68: mint, burn and position transfer project into the sameTransferIntentthe movement engine already evaluates, producing three receipts per action (request, decision, effect — including refusals). One set of rules governs both paths.
09 Roadmap
Ordered by dependency, not calendar. No dates until the previous item ships.
Prerequisite for every item below.
Close the 1 critical and 5 high findings from the 2026-08-08 internal review
(14–19 engineer-days). Two operational restrictions are active until then:
no recovery ceremony for wallets intended to hold value; no exposure of the
control plane beyond loopback on any machine another party can reach. Full
report at SECURITY-REVIEW-2026-08-08.md.
- Remediation. Ten actions closing C1 + the 5 high findings (14–19 days), then 6 medium + 4 low (20–29 days total). Re-run the sabotage tests after each fix.
- External audit. From a named firm (Trail of Bits / OpenZeppelin / Cure53 / NCC Group). Extend coverage to
uny-issuance,uny-issuance-flow,x402,vault-sdk— not reviewed at depth internally. - Wire custodian submission. Step 7 of
create_intentinservices/uny-api/src/routes.rstouny-bitgo, against the TEST environment only. OnDecision::Allow, submit via BitGo Express using the intent id assequenceId. A pending approval returned by BitGo is not a broadcast — record it distinctly. If Express is unreachable, fail loudly. Never report an unsent transfer as sent. This movesallowedfrom "policy passed" to "policy passed AND submitted for settlement" — with a separateBroadcastreceipt for the settled state. - Live attestation feed →
compute_ceiling. Pipe a real reserve-balance signal (chain adapter + off-chain attester) into the ceiling calculation. Staleness detection already exists; only the pipe is missing. /admin/reload. Re-read state file without a restart. Re-verify receipt chain on reload; refuse to swap in state that fails verification.Issuance ↔ policy pipe. Route mints through the same evaluator + receipt-chain writer that movements use.✓ Done infbacc68.- vault-sdk instrument surface.
uny_listInstruments,uny_transferVerdict. Dapps see instruments, holders, verdicts — same shape as movements today. Sits directly on top of the pipe above. - Client statement. Per-wallet PDF + JSON that a client's auditor can verify without contacting us — balances, every movement with its decision and reasons, receipt range with Merkle root, signer's public key, recovery-rehearsal date. Every figure traces to a receipt.
10 Appendix A · Error codes
All in the 4900 application-defined range — never conflicts with EIP-1193 standard errors.
| Code | Name | Meaning |
|---|---|---|
4001 | USER_REJECTED | Standard EIP-1193. Operator clicked Reject. |
4100 | UNAUTHORIZED | Standard. Session locked or origin not permitted. |
4200 | UNSUPPORTED_METHOD | Standard. Use to probe for vault-native features. |
4900 | DISCONNECTED | Standard. Extension not connected to control plane. |
4901 | CHAIN_DISCONNECTED | Standard. |
4910 | POLICY_DENIED | Vault. Rule refusal with named reason. |
4911 | PENDING_APPROVALS | Vault. Intent created, needs more signatures. |
4912 | TIMELOCKED | Vault. Approved, execution held until notBefore. |
4913 | RECOVERY_NOT_PROVEN | Vault. Wallet exists but ceremony not rehearsed. No funds can enter. |
11 Appendix B · Receipt format
Each entry on the append-only ledger is a canonical-JSON envelope, signed with ML-DSA-65:
{
idx: <u64 monotonically increasing>,
ts: <ISO 8601 UTC timestamp>,
actor: <enterprise::wallet id triple>,
decision: "allowed" | "pending_approvals" | "policy_denied" | "timelocked",
payload: <the exact intent as it was evaluated, byte-for-byte>,
prev: <hex sha-256 of the previous canonical envelope>,
signature: <ML-DSA-65 detached signature over sha-256(this record)>,
signer: <hex fingerprint of the ledger's public key>,
}
An auditor with the signer's public key can verify the entire chain
independently. If any record is altered, the prev chain
breaks; if a signature is forged, the ML-DSA-65 verification fails.
Both are detectable offline.
The signer's public key fingerprint is recorded at ceremony time and printed on the recovery cards. An auditor should record it out of band; a chain whose signer nobody wrote down is only self-consistent.