
Rolebound
Rolebound: Access follows the role, not the person. Clean team wallets with real accountability.
Demo
About
Rolebound is a way for onchain teams to give people spending power that has limits, can be taken back, and leaves a record. The problem When an organization wants to let someone spend its money, it has two bad options today: 1. Add them to the multisig. Their access has no limit and never expires. 2. Send them the budget. Once the money arrives, you can't get it back. Nothing sits in between, so teams hand out too much access by default. This causes three problems: - People get more authority than their job needs. - Recurring payments keep running after the person who set them up has left. - The answer to "why did we send 2,000 USDC?" is buried in a Discord chat. The core idea: the role holds the money, not the person In Rolebound, every role in an organization (Engineering, Marketing, Contractors, and so on) has its own wallet, created as a Privy server wallet with its own policy. - The budget is the balance. The treasury tops up role wallets, and a role's budget is simply what its wallet holds. No spreadsheet is needed. - People get grants, not keys. A contractor or teammate never holds keys or money. They get a grant on a role, with one of two abilities: spend or approve. - Every grant has limits. Grants are capped by the role's per-transaction limit and monthly budget. - Revoking is instant. Removing someone takes one click and works on their very next attempt. Since the money never moved to them, there is nothing to recover. Their recurring payments are listed so you can cancel them, move them to someone else, or keep them. How a payment works 1. A member enters an amount, a recipient and a reason. 2. Before the request leaves the browser, the member signs an EIP-712 authorization with their Privy embedded wallet. The signature covers that exact amount, recipient and reason. The server checks who signed and rejects any mismatch. This shows which person asked, not just which role paid. 3. Every payment goes through one check, assertCanSpend(). The web app, the agent Aall use it, so there is no second path where a limit could be skipped. 4. If a payment is over the role's per-transaction limit, it goes to an approver. The approver signs off with their own reason, and the payment then goes through. 5. The role wallet calls our RoleboundPay contract. The reason is hashed and saved ction as the payment, so the record can't drift from the transfer. The text of thereason stays private in the app. The activity feed reads the hash back from the chain and shows a "verified" badge only when it matches. Two layers of protection Rolebound's own rules are backed by rules in Privy's policy engine, which Privy enforces in its secure hardware (the enclave): - Only our contract: each role wallet can call RoleboundPay and nothing else. A dirapproval to any other contract, is blocked. - Only allowed recipients: payments can only go to addresses on the role's list. - Hard monthly cap: the enclave refuses anything over the role's monthly budget, even if an approver signs off. So even if our app server were hacked, an attacker still couldn't drain a role wallet to their own address. AI agents are members too An AI agent is added the same way as a person: same members table, same grants, same limits. The only difference is that it logs in with an API key instead of a session. The agent doesn't get its own wallet; it gets a role, and every rule above still applies. The record Activity is grouped by role and shows who paid, which role, how much, to whom and why, and all of it can be checked against the chain. Built with - Next.js 16, TypeScript, Tailwind and shadcn/ui - Privy: server wallets, policy engine, embedded wallets and authorization keys - Solidity contracts built with Foundry and deployed on Base Sepolia - viem, plus Drizzle with Neon Postgres - Hosted on Vercel The project has 160 tests, and none of them use mocks. They start a real local chain (Anvil) and a real Postgres database, deploy the real contracts, and move test money through the real payment check. Live demo: https://rolebound.vercel.app
How it's made
The architecture The app is built with Next.js 16 (App Router, Server Components and Server Actions) in TypeScript, hosted on Vercel. The UI uses Tailwind and shadcn/ui. The data (orgs, roles, grants, members, payments, schedules and the activity log) lives in Postgres on Neon, accessed through Drizzle ORM. All chain reads and writes go through viem. The contracts are written in Solidity with Foundry and deployed on Base Sepolia. The onchain part is deliberately small. RoleboundPay has a single function, pay(roleId, token, to, amount, reasonHash, actor). It rejects an empty reason hash, a zero address and a zero amount. It then calls transferFrom to move tokens straight from the role wallet to the recipient and emits a Payment event. The contract never holds funds. Because the reason hash is checked onchain, a payment without a reason is impossible even if someone bypasses our app. Every payment follows the same path: 1. The member signs in the browser. The pay form uses Privy's useSignTypedData with the member's embedded wallet. They sign an EIP-712 PaymentAuthorization covering the roleId, recipient, amount, keccak256(reason) and a nonce. 2. The server checks the signature. A server action receives the request. The member's identity comes from the Privy access token, which we verify against Privy's public JWKS keys. We fetch the member's embedded wallet address from Privy's API instead of trusting the browser. recoverTypedDataAddress must return exactly that address, or the request is refused. 3. The payment check runs. requestPayment() calls assertCanSpend(), which checks, in order: - there is a reason and the amount is valid - the role hasn't been dissolved - the member has a live grant (revokedAt IS NULL) - the recipient is on the role's allowlist - the payment is within the per-transaction cap - the role's total spending this month stays within its monthly budget If either limit would be exceeded, the payment is saved as pending_approval instead of being refused. 4. The role wallet sends the transaction. We encode the pay() calldata and send it with Privy's wallets().ethereum().sendTransaction() from the role's server wallet. We then wait for the receipt and write the tx hash and an activity event to the database. 5. The feed verifies it. The activity feed pulls Payment events off the chain with getLogs. It re-hashes the reason stored in our database and shows a "verified" badge only if the hash and amount match. A mismatch is shown clearly as a warning, never hidden. Browser payments, the AI agent API (POST /api/agent/pay with a Bearer rb_live_… key) and recurring payments (a Vercel Cron job hitting /api/cron/sweep) all call requestPayment(). There is no other route to a wallet signature. How we used Privy (our partner technology), and why it mattered Privy is not just the login here. It is the security foundation of the whole product. - Server wallets. Every role, and the treasury, is its own Privy server wallet, created through the API when the role is created. "The role holds the money" only works if creating a wallet is as cheap as inserting a database row, and with Privy it is. - Policy engine. Each role wallet gets exactly one policy with two ALLOW rules. Anything that doesn't match a rule is refused inside Privy's secure hardware (the enclave). - Rule 1: the transaction must go to RoleboundPay. If the role has an allowlist, pay.to must be on it. If the role has a monthly budget, pay.amount must not exceed it. - Rule 2: the wallet may call USDC.approve, but only when the spender is RoleboundPay. We create the policy first and the wallet second, so a wallet never exists without its limits, even for a moment. The result: even if our server were fully compromised, a role wallet still couldn't send its USDC directly or approve an attacker to spend it. We tested both on Base Sepolia, and both came back policy_violation. - Embedded wallets. Members log in with email and automatically get a wallet. That wallet signs each payment authorization, which lets us prove which person approved which payment. Otherwise, all we could say is that "a role paid". - Access-token verification. Server-side JWT checks mean the server never has to trust the browser about who the user is. Without Privy we would have had to build key custody, an enclave-backed policy system and wallet onboarding ourselves, and none of that would fit in a hackathon. Hacky and notable details - We tested Privy's rules in practice, and got caught when they changed. Before building, we wrote a test script (pnpm spike:policy) and ran it against a live Privy app. On 2026-09-10, rules based on calldata (amount and recipient) were accepted when the policy was created but ignored at signing. Only rules on the destination contract were enforced. So we built amount limits into the app and relied on Privy only for the contract allowlist. Two days later, while seeding the demo, a 1,200 payment was refused with policy_violation: Privy had started enforcing calldata rules. That broke our approval flow. We had put the per-transaction cap (500) into the policy, so the enclave was now refusing the exact payments an approver is meant to unlock. The fix made the design clearer: - The per-transaction cap is a soft limit held by the app. Going over it means an approver is needed. - The ceiling is a hard limit held by Privy: no single payment can exceed the role's monthly budget, and no approver can override it. We kept both findings in the PRD instead of rewriting history. - Two wallet modes, with guardrails. On a local Anvil chain, a local mode derives each wallet's key from the role name (keccak256("rolebound:local:<role>")) and stores the key inside the wallet ID, so no key storage is needed. Two checks keep this safe: - the local mode refuses to run on any chain except 31337 - the app refuses to start if Privy is configured while pointed at Anvil Without the second check, you'd get a confusing "Invalid wallet ID" error deep inside the SDK. - Getting local and live modes to coexist. pnpm chain:setup deploys the real contracts to Anvil, writes their addresses to .env.local and seeds an org. It also blanks the Privy variables, because .env.local takes priority over .env in Next.js. To run against Privy, pnpm dev:privy reads the real values from .env and injects them as process environment variables, which beat both files, so neither file needs editing. - Working around RPC load balancing. Base Sepolia's public RPC runs behind a load balancer. After a role wallet's one-time approve is mined, the next payment's gas estimate can hit a node that still sees an allowance of zero, which fails with a bare execution reverted. So after approving, we keep re-reading the allowance until it shows up, and only then send the payment. - Reading event logs on a public RPC. Base Sepolia limits eth_getLogs to 10,000 blocks per call, and the chain is past block 46M. So we scan in 10k-block windows, inclusive at both ends (the -1n avoids counting boundary events twice), and start at the contract's deployment block. We also set cacheTime: 0 on the latest block number, because viem's cached value would miss the payment the user just made. - Dissolving a role. When a role is closed, its remaining balance goes back to the treasury through the same pay() call. That means the treasury is automatically added to every role's allowed recipients. Otherwise, a role with an allowlist could never be closed. - Agent keys. Keys are random (rb_live_ plus 24 bytes, base64url) and stored only as a SHA-256 hash. A slow hash function isn't needed for high-entropy random keys and would only slow down every agent request. Reissuing a key invalidates the old one. - Securing the cron job. The cron endpoint moves money, so it is protected with CRON_SECRET and a constant-time comparison. If the secret isn't set, it refuses every request. Recurring payments belong to the role, so they keep running after the person who created them leaves. If nobody holds a live grant on the role, the payment is reported as "unstaffed" and not sent. - 160 tests with no mocks. Vitest's global setup builds the contracts with Foundry, starts its own Anvil instance and deploys the real contracts there. It also creates and migrates a separate test database, so real test money moves through the real payment path. It refuses to start if something is already running on its port, so it can never accidentally deploy to the development chain. The database URL is hard-coded to the test database because the suite wipes every table between tests. - Automated UI checks. Two Playwright scripts: - verify:ui visits every screen at 1280px and 390px, fails on horizontal overflow or tap targets that are too small, and runs a real over-cap payment to check that the result panel appears. - verify:login runs a real Privy login in a real browser. - The token. We deployed our own 6-decimal test USDC instead of using Circle's Base Sepolia USDC. Circle's faucet gives out only about 10 per day, and the demo moves thousands. RoleboundPay takes the token address as a parameter, so nothing in the payment flow depends on which token is used.