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, digits and dashes, 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") → trueEncrypted 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 StubTransferVerifier, which accepts every proof; it exists so the house can be driven end to end before the circuits are ready and must be replaced before real value goes in. 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.
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.
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.
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.
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
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 the stub: until the circuit is in, the chain checks that a transfer is well-formed, not that it is honest, so a malicious client could build deltas that do not cancel. 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.