Mechanism
Bankhaus is a private banking house on Robinhood Chain for people and for the agents that act for them. It wraps USDG into balances nobody but their owner can read, gives each wallet a handle, lets an owner put an agent under a mandate the contract enforces, and records letters of disclosure when a figure has to be shown to someone. Settlement is on chain, in 100 ms blocks, with gas in fractions of a cent.
Two layers. ConfidentialToken stores balances and transfer amounts as ElGamal ciphertexts on the alt_bn128 curve and checks each transfer with a verifier that learns none of the numbers involved. Everything else, names, mandates, request links and receipts, is plain contract state next to it, and the client combines both at settlement time. Sender and recipient addresses are visible the whole way through. Only the amount is hidden.
Accounts and handles
A wallet opens one record in AccountRegistry with createProfile(handle, kind). Handles are lowercase letters and digits, up to 32, unique, and shown as gwen.bankhaus. Changing a handle returns the old one to the pool; the wallet behind the record never moves. The compliance role can set a tier on a record and nothing else; the tier is consumed by whoever enforces limits elsewhere, never by the token.
createProfile("gwen", Personal) → gwen.bankhaus
resolveHandle("gwen") → 0x… (or zero)
handleAvailable("atlas") → truePasskeys
An account can be opened with a passkey instead of a wallet. The WebAuthn PRF extension gives the page a 32-byte secret tied to the passkey; from it the browser derives the account key on secp256k1 and the view key on bn254, every time you unlock, and stores neither. Transactions are signed in the page through a small provider, so the rest of the app sees the passkey as any wallet. A fresh account has no gas: /api/faucet sends it a few millionths of an ETH from the house, once per address, only to addresses that have never sent anything. Lose the passkey and you lose the account, the same trade as a wallet; a passkey synced by your platform survives a lost device.
Encrypted amounts
Each account registers an ElGamal public key and holds one ciphertext. A deposit folds amount·G into the message component with zero randomness, which keeps the running balance readable to the depositor's own view key and, until the first transfer, to anyone else; the ERC-20 transfer that funded it already said the figure. A transfer carries two deltas, Enc(−a) to the sender and Enc(+a) to the recipient, and the contract adds each to the matching balance by point addition. A withdrawal subtracts amount·G against a proof of solvency and releases the asset.
Balances are kept in cents: ConfidentialToken.unit = 10 000, and deposits and withdrawals must be whole units. Reading your own balance is a discrete log over at most 2²⁸, which the client does with a 2¹⁴-entry baby-step table built once per session using one batched field inversion, then giant steps in chunks with another. Cold, a few hundred milliseconds; with the last known balance as a hint, about thirty.
// lib/elgamal.ts — also what the browser runs
const me = keypair(BigInt(keccak256(await wallet.signMessage({ message }))) % ORDER);
await token.write.register([me.pk.x, me.pk.y]);
const ct = await token.read.encryptedBalanceOf([address]);
const cents = decrypt(ct, me.sk, { hint: lastKnown }); // null if not yours
const { senderDelta, recipientDelta } = transferDeltas(3_000n, me.pk, theirPk); // 30.00
await token.write.confidentialTransfer([to, senderDelta, recipientDelta, proof, signals]);The verifier is a separate contract behind setVerifier, so circuits can be upgraded without moving a balance. This build installs SigmaBulletsVerifier: a Σ-protocol linking the two ElGamal deltas, the sender's key and the balance after the transfer to two Pedersen commitments, and an aggregated 32-bit Bulletproof over those commitments, verified with the 0x06 / 0x07 precompiles. The public signals are built by the token from its own state, never taken from the caller. 1.8M gas a transfer, 0.96M a withdrawal, 1,792 bytes of proof. The token layer also has a freeze of its own, separate from the protocol pause, that halts value movement but keeps registration and verifier rotation working.
Agents and mandates
An agent is created by a registered owner with createAgent(signer, label, tier, token, perTx, daily, hitl, allowlist, allowlistOn). Its vault is an internal balance of AgentController, credited by fundAgent with what actually arrived. The signer key can call payInvoice and queueInvoice and nothing else. routeFor(agentId, recipient, amount) answers Settles, Queues or Refused with the same checks as a view, so an agent asks before it spends.
payInvoice per-tx ✓ window ✓ allowlist ✓ amount ≤ hitl → settles queueInvoice per-tx ✓ allowlist ✓ amount > hitl → waits for the owner approvePending re-checks the policy against current state, then pays rejectPending deletes the entry; nothing moved, nothing to reverse setAgentStatus pause / resume, vault and policy untouched rotateAgentSigner new key, same vault, same history revokeAgent irreversible; remaining balance returns to the owner
The x402 invoice id from an HTTP 402 challenge is passed through both paths into AgentPaymentExecuted, which is what an operator's webhook reconciles against.
MCP. npx -y bankhaus-mcp gives an agent the whole surface as tools: status, vault, route, pay_invoice, queue_invoice, balance, send (which returns the letter for that transfer), approve_pending, file_letter, verify_letter, create_request. Keys come from the environment and stay on the machine. See Agents.
Sub-agents
An agent can open an agent of its own with createSubAgent, signed by its key (or by the owner). The sub-agent has the same owner and token and a mandate that must fit inside the parent’s at the moment it is written: no higher tier, no larger per-transaction, daily or review ceilings, and when the parent runs an allowlist, a subset of it. Four levels at most. The parent moves money into the sub-agent’s vault with fundSubAgent and back with recallFromSubAgent; nothing enters or leaves the contract. Every settled spend is charged against the rolling day of every agent above (_chargeAncestors), so a tree of agents is bounded by the root mandate however many branches it has; a paused or revoked agent stops everything below it. The parent’s key can pause, re-key, re-mandate, approve for and retire its sub-agents, and never itself. Retiring a sub-agent returns its vault to the parent. routeFor walks the ancestors, so an agent can ask before it spends. On paper: createSubAgent, fundSubAgent; in bankhaus-mcp: create_sub_agent, fund_sub_agent; in the terminal: bankhaus agent sub. Try it at /agents#subagents.
The board
A company account. BoardFactory.open(members, threshold, label) deploys a Board: an address with n members and a threshold k that holds a sealed balance, owns agents and approves their queue. There is no proposal storage. Members sign an EIP-712 Call(to, value, data, nonce, deadline) off chain; anyone holding at least k signatures, ordered by signer address, submits exec, which checks each signer is a member, bumps the nonce and performs the call. A signed call cannot be replayed (nonce), cannot outlive its deadline, and cannot be executed by fewer than k. The board changes its own membership only through a signed call to itself; the factory keeps no power over it. The board's view key is shared among members off chain, like the combination of an office safe; sealed transfers from it carry the usual proof, bound to the board as sender.
Letters
A letter of disclosure opens one transfer to one reader: the figure and the randomness the sender used. The reader re-encrypts and compares with the delta on chain. DisclosureLog.file(txReference, keccak(reader), keccak(payload)) records that it happened and when, with neither the counterparty named nor the proof published. Receipts are listed per account and per transfer.
Envelopes
A letter in the clear tells its figure to whoever holds the text. An envelope encrypts the letter to the reader's registered key: an ephemeral k, R = k·G on the envelope, S = k·pk as the shared point, AES-256-GCM under keccak(S). The reader computes S = sk·R with the same view key that reads their balance; any other key fails the tag and learns nothing, not even a wrong letter. Payslips, X-PAYMENT headers and letters to auditors can all travel this way. Nothing about an envelope goes on chain; the receipt stays keccak(reader), keccak(letter), when.
seal(letter, "atlas.bankhaus", pk_atlas) → -----BEGIN BANKHAUS ENVELOPE----- { to, R, iv, ct }
open(envelope, sk_atlas) → the letter, or an error
bankhaus letter seal 0x7c1e… --to atlas · bankhaus letter open - --as atlas
x402_pay(required) → X-PAYMENT carries the envelope by defaultRange letters
A range letter proves that a transfer carried at least T, or that a balance holds at least T, and nothing else. The prover commits to the difference, figure minus T, in a Pedersen commitment V, links V to the ciphertext with a Σ-protocol (the same relations the transfer proof uses, with V + T·G in place of the amount commitment), and runs the 32-bit Bulletproof on V, so the difference is non-negative. The letter carries the threshold, the ciphertext, the public key and the proof: no figure, no randomness. The reader verifies offline. If the figure is below T the prover cannot build the letter at all. A transfer range letter needs the figure and the randomness, so the sender writes it; a balance range letter needs the view key, so the account holder writes it. writeRangeLetter on paper, bank.rangeLetter in the SDK, range_letter in bankhaus-mcp, bankhaus letter range in the terminal.
Audit export
An audit file opens a period rather than a transfer: every sealed transfer the account sent between two dates (inclusive, UTC), each as a letter (figure, randomness, recipient key, delta), the total, and a digest keccak(ref|cents|r per line). The reader verifies each line offline as a letter, checks the digest and the sum, then checks completeness: the account’s ConfidentialTransfer events for the period on Robinhood Chain, with the recipient delta read from each transaction’s calldata (one row per leg for multi-path), must be exactly the lines in the file. A transfer on the ledger that the file omits is reported as missing; a line the ledger does not have, as extra; a cent moved after writing fails the digest. Nothing outside the period and no balance is in the file. It travels in an envelope like any letter. writeAuditFile on paper, bank.audit and live.auditLedger in the SDK, audit_file and verify_audit in bankhaus-mcp, bankhaus audit write | verify --chain in the terminal. Try it at /audit.
Requests to pay
RequestLedger.create(receiver, token, confidential, amount, commitment, memoHash, expiresAt) issues a link. A confidential request passes a commitment instead of a figure; the payer learns the figure and its blinding out of band, usually from the request's encrypted memo, and hands both back to fulfill. Receipts can route to a treasury that is not the requester's own wallet. Expired or paid requests refuse a second payment.
x402
A server that wants paying answers 402 Payment Required with its requirements; the client pays and retries with an X-PAYMENT header. Bankhaus offers two schemes on eip155:4663. bankhaus-sealed pays a handle with a confidential transfer and puts the letter of disclosure for that transfer in the header: the payee checks offline that Enc(cents, r, pk) equals the delta, that the letter names them, and that the figure is the one required; on chain they also match the delta against the transfer's calldata. The server learns the figure it asked for. The chain learns nothing. bankhaus-mandate lets an agent pay plain USDG from its vault through payInvoice, with the invoice id derived from the 402, so caps, allowlist and queue apply before any money moves.
402 {"x402Version":1,"accepts":[{"scheme":"bankhaus-sealed","network":"eip155:4663",
"maxAmountRequired":"4200000","payTo":"bob.bankhaus","resource":"https://api.example/report", …}]}
pay x402_pay(required) → X-PAYMENT: base64({ scheme, network, payload:{ kind:"sealed", letter } })
check x402_verify(payment, required) → ok, X-PAYMENT-RESPONSE: base64({ success, transaction, payer })Amounts in requirements are atomic USDG, six decimals, and must be whole cents. lib/x402.ts builds and checks payments offline; lib/x402-server.ts is the payee's settlement: open the envelope, verify the letter, then confirm on chain that the transaction the letter names is a call to ConfidentialToken that succeeded and carries exactly that recipient delta to the payee, single transfer or multi-path leg, and keep the reference so it cannot pay twice. A live endpoint runs at /api/report.
Metered x402
A third scheme, bankhaus-stream: the server’s 402 asks for a stream rather than a payment (meteredRequirementsFor: a rate per period, a period, a count; maxAmountRequired is the total). The client opens a sealed stream to the payee on those terms, one proof, and retries with X-PAYMENT carrying the stream id and a letter that opens the rate to the payee (ref stream:<id>, cents = rate, delta = the recipient rate ciphertext). The server verifies the letter offline (verifyStream) and, on chain, that the stream pays it, is open, runs on the required period for at least the required count and holds exactly that recipient rate (settleStream). From then on it serves while periods accrue and releases when it likes; the client stops paying by cancelling. One proof per session instead of one per request, and the price never appears on chain. Live at /api/metered: a tick a request, 0.01 USDG a minute.
Payroll
A payroll run is one confidential transfer per line and one letter per payee. Each letter opens that one transfer to that one person: it is the payslip. The chain holds as many rows as there are people and not one figure, so neither the total nor any salary can be read off it; the owner can still answer an auditor line by line, with receipts. Paste handle, amount, memo lines on the payroll page, or call payroll in bankhaus-mcp with a list of recipients and amounts. Up to 50 lines a run.
Sealed streams
A stream pays one sealed rate per period for a number of periods. The payer encrypts the rate twice, to their own key (rateS) and to the recipient’s (rateR), and calls openStream(to, rateS, rateR, period, count, proof). The contract derives the escrowed deltas itself, count × rateS negated for the payer and count × rateR for the recipient, and checks the ordinary transfer proof against them, so the proof is bound to the sealed rate and the count: a different rate or count fails. The total leaves the payer’s balance at once. accruedPeriods is elapsed time over the period, capped at the count, minus what was released: a public number. releaseStream, callable by anyone, adds periods × rateR to the recipient’s balance; because ElGamal is homomorphic, k times a ciphertext of the rate is a ciphertext of k times the rate under the same key, so no proof is needed and no figure appears. cancelStream, payer only, releases what accrued and adds the remaining periods × rateS back to the payer. The chain sees who, when and how many periods; never the rate. On paper: openStream, releaseStream, cancelStream; in the SDK: bank.streams; in bankhaus-mcp: open_stream, release_stream, cancel_stream, stream; in the terminal: bankhaus stream. Try it at /streams.
Payroll by streams
payrollStreams(lines, period, count) opens one sealed stream per line, the line’s amount being the rate per period, and writes one payslip per person: the letter that opens their rate. A month of salaries becomes, say, 160 streams of an hour each, opened once with one proof per person; everyone releases what accrued when they like, and next month needs no run. In the terminal: bankhaus payroll file.csv --stream hour --for 160; in bankhaus-mcp: payroll with stream; in the SDK: bank.payroll(lines, { stream: { period, count } }).
Multi-path payments
confidentialTransferMulti(Path[]) settles one to eight legs in one transaction, all or nothing. Each leg is a full confidential transfer with its own proof, built against the balance as it stands after the legs before it; the token builds each leg's public signals from its own storage in order. The client cuts the figure at random across the legs to the payee and may add cover legs of zero to other handles: a zero leg is provable, costs the same, and re-randomises the recipient's ciphertext exactly as a paid leg would, so the chain cannot tell them apart. There is no bundle id on chain; the bundle letter, one letter per paid leg with the total, is what the payee holds.
Hidden recipient (poste restante)
Get paid without the chain knowing it was you. A recipient publishes a meta-address once on PosteRestante: a spending point S and a viewing point V on secp256k1, both derived from the same wallet signature as the view key. A payer picks an ephemeral r, publishes R = r·G, and derives from shared = keccak(r·V) both a one-time account, address(S + shared·G), and a one-time Bankhaus view key, keccak(shared) mod n. announce has the token register that key for the account through registerFor, which only the counter may call; the payment is then an ordinary sealed transfer to an address with no past. The recipient scans announcements with v: a one-byte view tag first, then the address, then the key; a match yields the spending key s + shared and the view key. If a real owner ever claims an address the counter registered, their own register replaces the key. Spending from a one-time account links it to wherever the money goes next, so spend to payees directly or over several paths.
Fees
One contract prices everything. FeeSchedule.quoteFee(payer, amount) returns min(amount × baseFeeBps / 10 000, feeCap); the default is 0.10% capped at 5 USDG and the ceiling the schedule will accept is 1%. Fees are off at deployment and stay off until ProtocolAuthority.setFeeConfig(treasury, schedule) sets both. Agents pay out of the vault and the fee is left out of the mandate arithmetic; requests add it to what the payer owes so the receiver gets the full amount.
The token
$BANKHAUS is an ERC-20 on Robinhood Chain: one billion supply, eighteen decimals, launched on pons from the dev wallet. Contract address 0xefb57fd8fd62dbe2f7f486e56aa6f81fe83f43f9. It is not required to use the bank and carries no fee share today; the fee schedule, when it is switched on, routes to the treasury.
The SDK
bankhaus-sdk is the house as a library, for servers and agents written in TypeScript. One interface, two modes: Bankhaus.paper() is test mode, a ledger in memory with the real arithmetic and the real proofs; Bankhaus.live({ privateKey }) talks to the contracts on Robinhood Chain and keeps the key in your process. Deposit, send (sealed, multi-path, hidden recipient), payroll, letters and envelopes, and x402 on both sides, including on-chain settlement. There are no API keys because there is no server of ours to hold one.
import { Bankhaus, x402 } from 'bankhaus-sdk';
const bank = Bankhaus.paper('gwen'); // or: await Bankhaus.live({ privateKey })
await bank.deposit('100');
await bank.send('bob', '25'); // sealed, with a proof
await bank.send('bob', '120', { paths: 3, cover: ['carol'] });
await bank.send('bob', '80', { hidden: true });
const need = x402.paymentRequired({ payTo: 'bankhaus', usdg: '0.25', resource: url });
const r = await x402.settle(header, need.body.accepts[0]); // offline, then on chainThe terminal
The house as a command. npx -y bankhaus keeps the ledger on paper in ~/.bankhaus/account.txt, the same block the site exports, so an account carries between the terminal, the browser and the phone. Every figure is decrypted on the machine that holds that file. Live mode reads and writes the deployed contracts; paper mode keeps running with the same arithmetic for anyone who wants to try the house without a wallet.
npx -y bankhaus open gwen # keys generated here, kept in ~/.bankhaus npx -y bankhaus deposit 250 && bankhaus send bob 12.34 npx -y bankhaus letter write 0x1c28… --to auditor | bankhaus letter verify - npx -y bankhaus payroll october.csv --out slips/ # one transfer per line, one payslip each npx -y bankhaus x402 pay 402.json # prints the X-PAYMENT header npx -y bankhaus agent new concierge 50 200 20 --allow bob,cloud-bill npx -y bankhaus statement · log · export · import
Run it
npm install npm run compile # solc 0.8.24, paris, via-ir → contracts/out/artifacts.json npm run test:contracts # 54 checks on an in-process EVM, real ElGamal, same bytecode npm run test:crypto # lib/elgamal.ts alone npm run dev # http://localhost:3012
Activity
The activity page reads the contracts' events straight from Robinhood Chain on every load, with no database in between: lib/activity.ts pulls the logs of all seven contracts since the deployment, decodes them with the generated event list in lib/events.ts, resolves handles through the registry and shows facts only. A sealed transfer appears as who, whom and when; its figure is not on chain and so cannot appear. /api/activity serves the same feed as JSON, cached for twenty seconds.
Alerts and webhooks
Alerts run where your keys are. On the account page, Alert me turns events that touch your address into system notifications while the page or the Android app is open; nothing is subscribed anywhere else. In your own process, alerts.watch in the SDK or bankhaus watch in the terminal polls the chain every twenty seconds, keeps the events that touch your addresses, and hands them to a callback or posts them to a webhook signed with X-Bankhaus-Signature: t=…,v1=HMAC-SHA256(secret, t.body), which alerts.verifyWebhook checks with a five-minute window. Agents poll recent_activity in bankhaus-mcp. There is no subscription list on any server of ours, and no event ever carries a figure.
Addresses
Deployed on Robinhood Chain (chain id 4663) on 2026-10-19. USDG is 0x5fc5360d0400a0fd4f2af552add042d716f1d168; one unit is one cent.
ProtocolAuthority 0xc07bd812d22c874a7cc90c2890371289c05b233f AccountRegistry 0x0e8e9ff97953821dd312746e1f299fafbad2291d AgentController 0xbc2d5db377e186aee6f589756c1240504fb44f76 RequestLedger 0x9e764cd06a9a0e674805a7bcc59d7f1e25fa9e1f DisclosureLog 0x85a946daa9053d355616c2dda9e99955e5a82f7a SigmaBulletsVerifier 0x7f6b4b2e26467210cf14c27b01b057d2695d31ee ConfidentialToken 0x2ac72c1dbe9aa3d03d4af67996dc8a709f36d59f PosteRestante 0x9258a2903f4fe5cd39cb7934badca3fae1a3613d BoardFactory 0xebea52a82d5173c30b2df82aee5e8785686955be
- ProtocolAuthority on Blockscout ↗
- AccountRegistry on Blockscout ↗
- AgentController on Blockscout ↗
- RequestLedger on Blockscout ↗
- DisclosureLog on Blockscout ↗
- SigmaBulletsVerifier on Blockscout ↗
- ConfidentialToken on Blockscout ↗
- PosteRestante on Blockscout ↗
- BoardFactory on Blockscout ↗
Deploy
cp contracts/.env.example contracts/.env # PRIVATE_KEY, COMPLIANCE_AUTHORITY, USDG_ADDRESS npm run deploy # writes deployments/4663.json; the app goes live on the next build
Until then the account page runs on paper: the same arithmetic, in the browser, against a ledger in localStorage, with a built-in counterparty so transfers can be exercised. The page says which mode it is in.
What can go wrong
The verifier is young: Σ-Bullets is a known construction and the tests cover every lie we could think of, but the code has not been audited. Balances above 2^32 cents (42,949,672.95 USDG) cannot be proven over, by design of the range. The deposit step is public by construction. A lost wallet is a lost view key, since the key is a signature over a fixed message; a second wallet can be registered but cannot read the first one's history. USDG is what it is on Robinhood Chain, an issuer's promise. Nothing here is investment advice.
Provenance
The contracts descend from the MIT-licensed Sectoral contracts for Robinhood Chain. Bankhaus keeps the architecture, moves balances to cent units, renames the handle suffix and replaces Foundry with a solc-js build and an in-process EVM harness. The original MIT headers are preserved in every file; see contracts/NOTICE.md.