UNYKORN Wallet — developer reference
Integrate UNYKORN Wallet in any web dapp. Speaks EIP-1193 fully so every
existing dapp works unmodified. Announces itself via EIP-6963 to any
wallet-selection UI. Adds a uny_* extended namespace for
what a custody rail actually does — multi-approver movements, timelocks,
policy-refused intents, agent-safe treasury via x402 capability tokens.
Overview
The vault-sdk provider is not a MetaMask clone. MetaMask's model is: dapp asks for a signature, user clicks Confirm, done. That model has no vocabulary for the thing a custody platform does constantly — "this movement is valid but needs a second approver and a 24-hour timelock." A standard EIP-1193 provider can only answer that with a rejection, which is wrong and which teaches users to retry.
This provider speaks EIP-1193 fully — every existing dapp works
unmodified. Where the honest answer is representable, it uses the
uny_* namespace to say what the state actually is.
Quickstart
Install the SDK from npm (planned; today, source-import from the repo):
# Once published:
npm install @unykorn/vault-sdk
# Today, from source:
npm install github:FTHTrading/wallet#main --workspace-root
Import it and use the standard EIP-1193 flow:
import "@unykorn/vault-sdk";
// Legacy: window.ethereum still works
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
// Preferred: EIP-6963 discovery
window.addEventListener("eip6963:announceProvider", (event) => {
const { info, provider } = event.detail;
if (info.rdns === "ai.unykorn.wallet") {
// vault-native
}
});
window.dispatchEvent(new Event("eip6963:requestProvider"));
Trust boundary
Untrusted page, trusted extension. The provider runs in the page. It holds no keys, no session secret, no policy state. Everything it does is a message across the content-script bridge to the extension service worker, which re-derives permissions from its own storage. A hostile page can call every method and learn nothing it was not granted, and move nothing policy did not allow.
EIP-1193 — standard methods
Every standard EIP-1193 method is implemented and forwarded to the selected chain provider (EVM chains via the vault-sdk's internal wallet handling; non-EVM chains via chain-specific adapters). Common ones:
| Method | Behaviour |
|---|---|
eth_requestAccounts | Opens the extension popup for the operator to select which wallet the dapp gets access to. |
eth_chainId | Returns the current EVM chain the wallet is scoped to. |
eth_sendTransaction | Creates a movement intent. If policy allows and quorum is met, it broadcasts. If quorum is unmet, throws 4911 PENDING_APPROVALS. |
personal_sign, eth_signTypedData_v4 | Standard signing. Policy engine still evaluates the signing wallet's session state. |
wallet_switchEthereumChain | Standard switch. Wallet may refuse chains its policy doesn't include. |
EIP-6963 — provider discovery
EIP-6963 replaces the window.ethereum monkey-patching war. Each
wallet announces itself with a unique uuid, a reverse-DNS
identifier, a display name, and an icon.
const providers = [];
window.addEventListener("eip6963:announceProvider", (event) => {
providers.push({
info: event.detail.info, // { uuid, name, rdns, icon }
provider: event.detail.provider,
});
});
window.dispatchEvent(new Event("eip6963:requestProvider"));
UNYKORN Wallet's identifiers:
| Field | Value |
|---|---|
rdns | ai.unykorn.wallet |
name | UNYKORN Vault |
uuid | generated per extension install |
uny_* extended namespace
The vocabulary EIP-1193 does not have. Standard providers can say YES or NO. UNYKORN can also say:
pending_approvals— the intent is created and recorded; it needs additional signatures.timelocked— approved, cannot execute untilnotBefore.policy_denied— refused for a stated reason (not user rejection).recovery_not_proven— the wallet exists but its recovery ceremony has not been rehearsed. No funds can enter.
uny_capabilities
A dapp that wants to know whether it's talking to a vault-native provider
probes this method. Vault-native providers return an object; standard
providers throw 4200 UNSUPPORTED_METHOD.
try {
const caps = await provider.request({ method: "uny_capabilities" });
// { version: 1, extended: ["intent","approvals","timelocks","x402"] }
} catch (err) {
if (err.code === 4200) {
// Standard EIP-1193 wallet — fall back to eth_sendTransaction only.
}
}
uny_intent
Explicitly create a movement intent — same shape as
eth_sendTransaction but returns the intent object instead of
throwing on pending approvals.
const intent = await provider.request({
method: "uny_intent",
params: [{
wallet: "wal_m-helen-escrow",
asset: "base:USDC:0xa0b869…",
amount: "96102151", // integer minor units (USD × 100)
destination: "0x00000000000000000000000000000000000dEaD",
}],
});
// {
// status: "pending_approvals",
// code: 4911,
// intentId: "intent_qz3f1x8k",
// required: 2, current: 1,
// approvers: [{ role: "signer_plane", signed_at: "…", fingerprint: "8220…" },
// { role: "policy_officer", signed_at: null, fingerprint: null }],
// poll_at: "/intent/qz3f1x8k"
// }
uny_listWallets
Enumerate wallets the current session is authorised to see.
const wallets = await provider.request({ method: "uny_listWallets" });
// [{ id, name, threshold: "2-of-3", recovery_proven: true, chain, floor_reported }, …]
x402 capability token — shape
x402 is the layer neither BitGo nor MetaMask has: a signed grant that authorises an agent to CREATE INTENTS only. It cannot sign, cannot approve, cannot raise its own budget. Fully compromised, an agent can only spend its remaining budget to destinations already on its allowlist — and every unit lands on the receipt chain.
{
header: {
typ: "uny-cap",
v: 1,
iss: "<issuer public key, hex>",
},
claims: {
sub: "x402-payer", // agent subject
enterprise: "ent_unykorn-llc",
wallet: "wal_m-helen-escrow",
asset: "base:USDC:0xa0b869…",
budget: "50000000000", // integer minor units, decimal string
perTxCeiling: "5000000000",
destinations: ["0x00…dEaD", "0x9c…8e"],
notBefore: 1786100000,
expiresAt: 1786160000,
nonce: "cap_payables_q3", // revocation handle
},
signature: "<Ed25519, hex>",
}
Signature: Ed25519 over SHA-256(domain || canonical(header) ||
canonical(claims)). Domain separator:
unykorn.vault.capability.v1.
No JWT. Algorithm agility is the vulnerability — alg:none
and RS256/HS256 confusion have each produced real custody incidents.
There is one algorithm, one domain separator, no negotiation surface.
Mint a capability
import { mintCapability, generateIssuerKey } from "@unykorn/x402";
const { privateKey, publicKeyHex } = await generateIssuerKey();
const token = await mintCapability({
sub: "payables-q3",
enterprise: "ent_unykorn-llc",
wallet: "wal_m-helen-escrow",
asset: "base:USDC:0xa0b869…",
budget: "50000000000",
perTxCeiling: "5000000000",
destinations: ["0x00000000000000000000000000000000000dEaD"],
notBefore: Math.floor(Date.now() / 1000),
expiresAt: Math.floor(Date.now() / 1000) + 21600, // 6h
nonce: "cap_payables_q3",
}, privateKey, publicKeyHex);
Verify a capability
import { verifyCapability } from "@unykorn/x402";
const claims = await verifyCapability(
token,
["<trusted issuer public key hex>"],
Math.floor(Date.now() / 1000),
revokedNonces, // Set<string> of revoked nonces
);
// Throws CapabilityError with .code for any failure (bad_signature,
// expired, revoked, untrusted_issuer, not_yet_valid, malformed).
Authorize spend + revoke
import { CapabilityLedger } from "@unykorn/x402";
const ledger = new CapabilityLedger();
const { remaining } = ledger.authorize(claims, {
asset: "base:USDC:0xa0b869…",
amount: "400000000",
destination: "0x00000000000000000000000000000000000dEaD",
});
// { remaining: "49999600000" }
// Rejects (never clamps) with .code: over_per_tx_ceiling, over_budget,
// destination_not_allowed, asset_mismatch, revoked.
ledger.revoke("cap_payables_q3"); // nonce revocation, instant
Custody-native error codes
All in the 4900 application-defined range — never conflicts with EIP-1193 standard errors.
uny_capabilities; catch 4200 to detect non-vault providers).intentId, required, current, approvers[].notBefore.Handling in dapps
try {
await provider.request({ method: "eth_sendTransaction", ... });
} catch (err) {
switch (err.code) {
case 4001: // user rejected — normal, retry-safe
break;
case 4910: // policy denied — show the reason, do NOT auto-retry
showPolicyReason(err.data.reason);
break;
case 4911: // pending approvals — render a queue, poll err.data.poll_at
startApprovalPoll(err.data.intentId);
break;
case 4912: // timelocked — show countdown to err.data.notBefore
showTimelock(err.data.notBefore);
break;
case 4913: // recovery not proven — direct the operator to the ceremony
redirectToRecovery(err.data.walletId);
break;
}
}
Glossary
| Term | Meaning |
|---|---|
| allowed | Policy passed. Does not mean value moved. Custodian submission is a separate step and is currently gated. |
| attestation ceiling | Supply hard-cap on a licensed issuer's instrument. Median of a signed verifier quorum. Recomputed per mint — never cached. |
| control plane | The uny-api service on 127.0.0.1:7331. Holds the policy engine, the receipts ledger, the issuance register. |
| enterprise | Top-level tenant scope. All wallets, capabilities and receipts are namespaced by enterprise id. |
| intent | A proposed movement. Created by the operator or by a capability. Recorded on the receipt chain immediately, even if not yet approved. |
| ProvenRecoveryPlan | The only input type create_wallet accepts. Only source: a rehearsal that reconstructed the key AND proved t-1 fails. |
| receipt | An ML-DSA-65 signed record on the append-only ledger. Every decision produces one. |
| signer plane | The wallet-scoped signing service. Runs the policy engine independently of the control plane — same evaluation, different plane. |
| settlement venue | How an issuer's instrument settles: BookEntry, ATS, OnChainPermissioned, or OmnibusCustody. |
Migrate from window.ethereum
Most integrations don't need any changes. The vault provider speaks EIP-1193 fully. But you should:
- Switch to EIP-6963 discovery —
window.ethereummonkey-patching wars are why standard-provider handling breaks under multiple wallets. - Handle
4911 PENDING_APPROVALSas a queue state, not a failure. Pollerr.data.poll_atuntil settled or expired. - If you can, add a fast-path for
4910 POLICY_DENIED— show the reason, do not auto-retry (the retry will refuse again for the same reason and just wastes user attention). - Never conflate "we got a successful
eth_sendTransactionresponse" with "money moved".allowedmeans policy passed. Broadcast is a separate receipt.