
Procure
Procure-to-pay for onchain companies. Unmatched invoices can't be paid — the contract refuses.
Demo
About
Procure is a multi-tenant B2B procure-to-pay system for companies whose treasury is onchain. Purchase requests, approvals, purchase orders, goods receipt, vendor invoicing, three-way match, double-entry accounting, and settlement in USDC on Arc. The problem. A company with an onchain treasury has no way to run procurement without trusting a person or a server. A multisig approves transfers, not purchases — the signer has no idea whether the goods arrived or whether the invoice matches what was ordered. ERPs know all of that and enforce none of it cryptographically: "approved" is a database field, and invoice fraud is a multi-billion-dollar problem because of it. Every control sits in software beside the money rather than in it. What we built. The claim is simple: an invoice that doesn't match its purchase order cannot be paid — not by policy, by construction. The cycle: a requester raises a request against their department's budget → approvers sign it with their own Privy wallet (EIP-712, free, instant, no gas, no transaction) → a purchaser issues the order and its terms are fixed onchain before anyone can act on them → the requesting side confirms delivery, never whoever pays → the vendor submits their own invoice from a separate vendor portal, typing their own figure → the three-way match compares all three, and ProcureRegistry.executePayment on Arc recovers every approver signature, checks it against an onchain approver set and threshold, checks the vendor allowlist, the budget and replay — then transfers the USDC, or reverts. Why signatures rather than transactions. Arc charges gas in USDC, so one transaction per approval would mean funding every approver's wallet before they could act. Instead approvers sign off-chain for free and one transaction carries every signature to the chain, which counts them before the money moves. It's a cheque: you sign at your desk, the bank verifies at the counter. Compromise our server and you still cannot pay an unapproved vendor an unapproved amount. Vendor risk screening — where The Graph does real work. Before a vendor's payout address can receive anything, it is screened through The Graph's Token API across Ethereum mainnet, Base and Arbitrum. Seven weighted signals are computed from raw transfer history: Address age — binary-searched on existence rather than trusting returned timestamps, which are unreliable under rate limiting Forwarding behaviour — does money arrive and immediately leave? A pass-through address is not a business Counterparty diversity — how many distinct parties has it genuinely traded with Activity level — sustained commercial movement, not a single spike Account type — EOA, contract, or EIP-7702 delegated EOA Dust activity — explicitly detected and reported, not silently dropped Stablecoin holdings — current balances across all three chains These produce a 0–100 score and a band: CLEAR, REVIEW or BLOCKED (thresholds configurable per deployment). An LLM writes the narrative assessment from the computed signals, never from raw data, so it explains a decision rather than making one — with a rule-based fallback when no key is present. Results render as a weighted breakdown plus an interactive 3D counterparty graph, where dust nodes are hidden by default except when there are fewer than three commercial counterparties — because then the dust is the finding. Crucially this is not advisory. A vendor's status drives setVendorAllowed on the contract, so a blocked address leaves the onchain allowlist and executePayment refuses it regardless of what our database later says. Making it spam-resistant took two rounds, both found by testing against real addresses. An unused vanity address scored 100/100 — worthless airdropped tokens looked like counterparty diversity and holdings. We switched to screening stablecoins only; it scored 100 again, because address-poisoning attacks send genuine sub-dollar USDC to lookalike addresses. A $10 dust floor fixed it. Final spread: Circle treasury 88, Vitalik 100, a poisoned vanity address 60, a never-used address 47. Privy — org and department wallets, policies, and the approver signatures the contract verifies. Arc rejects eth_sendTransaction, so Privy signs and we broadcast; the policy engine still fires at the signer. Arc — USDC as both gas and settlement. A full cycle costs under a cent. Also built: configurable approval workflows (levels, quorum, amount thresholds, per org per module, snapshotted onto each request so later edits can't move the bar), auto-settlement of matched bills, a double-entry ledger where GR/IR clearing nets to zero only when receipt and invoice agree — making the exception report a number that won't go away — trial balance with CSV export, PDFs for all five documents, and transactional email at every step. Live: ProcureRegistry at 0xa033ac540710763422618453C80AA026c0FE11f0 on Arc testnet, 14 contract tests (mostly revert paths), and a completed cycle settling real USDC in tx 0x6a698c9213bc3b489ccbec8e4c707132f9c1d77193503ae4de815c3fd4180039.
How it's made
--- ## Stack **App:** Next.js 16 (App Router, React 19, server actions — no separate API layer), TypeScript, Tailwind. **Data:** Postgres on Supabase, Prisma 7 with the `@prisma/adapter-pg` driver adapter. **Contracts:** Solidity 0.8.24, Foundry, deployed to Arc testnet. **Chain access:** viem. **Auth & wallets:** Privy — `@privy-io/react-auth` on the client, REST for server wallets. **Indexing:** The Graph's Token API via Pinax. **Email:** Resend behind a `Mailer` interface. **AI:** GPT-4o for risk narratives. **3D:** react-force-graph-3d. ## How it fits together Postgres holds documents and workflow state. The contract holds **spend authority**. The two stay in step through explicit sync points: saving an approval level writes `setApprovers` to Arc, issuing a purchase order writes `commitOrder`, activating or blocking a vendor writes `setVendorAllowed`. Every chain write is signed by the organisation's Privy treasury wallet and broadcast by us. `ProcureRegistry` stores, per organisation: the approver set and threshold for each level, the vendor allowlist, the budget ceiling, and each order's committed terms. `executePayment` recovers every approver signature, checks each against the on-chain set, counts them against the threshold, verifies the payee is allowlisted and the amount is within budget and unpaid — then moves USDC, or reverts. ## Privy Privy gives every actor a wallet without anyone touching a seed phrase: email OTP sign-in, an embedded wallet per user, and server wallets for the org treasury and each department. The critical use is **approvals as signatures**. An approver signs an EIP-712 struct naming the order hash, level and amount with their own embedded wallet — free, instant, no gas, no transaction. We sign through the wallet's EIP-1193 provider (`eth_signTypedData_v4`) so approval is a single click with no dialog, which matches what the action actually is. On the treasury side, an integration detail worth knowing: **Arc doesn't accept `eth_sendTransaction` through Privy**, but `eth_signTransaction` with `chain_id` inside the transaction returns signed RLP — and Privy's **policy engine still evaluates on that path** (a disallowed recipient is rejected before signing). So the split is deliberate: **Privy holds the key and enforces policy; we own the RPC and broadcast to Arc.** Both control layers stay intact, and the vendor allowlist is enforced twice — once at the signer, once in the contract. Before writing any Solidity we validated the premise the whole design rests on, by calling Arc's `ecrecover` precompile directly via `eth_call`: a Privy signature recovers to the approver's address on-chain, exactly as `ECDSA.recover` will inside the contract. ## The Graph Every vendor payout address is screened through the Token API across **Ethereum mainnet, Base and Arbitrum** before it can receive anything. We pull raw transfer history and balances and compute seven weighted signals ourselves — address age, forwarding behaviour, counterparty diversity, activity level, account type (including EIP-7702 delegated EOAs), dust activity and stablecoin holdings — producing a 0–100 score and a CLEAR / REVIEW / BLOCKED band. Two design decisions made it robust: - **Stablecoin-only screening with a $10 dust floor.** Raw token history is full of airdropped junk and address-poisoning dust, both of which imitate legitimate trading. Filtering to stablecoins above a commercial threshold means the score reflects real payments. - **Address age is binary-searched on existence**, not on returned timestamps, and the search is skipped entirely when the oldest sampled transfer already proves the address is over a year old — keeping a full screen inside the free tier's rate limits. GPT-4o writes the narrative **from the computed signals**, never from raw data, so it explains a decision rather than making one. Results render as a weighted breakdown plus an interactive 3D counterparty graph. The Graph turns screening from advice into enforcement: a vendor's band drives `setVendorAllowed`, so a blocked address leaves the on-chain allowlist and the contract refuses it. ## Arc Arc is the settlement layer and the reason the economics work. **USDC is both the gas token and the money**, so a vendor is paid in the same asset that pays for the transaction, and a complete cycle — register approvers, commit an order, allowlist a vendor, execute payment — costs **under a cent**. That also shaped the architecture. Because gas is USDC, one transaction per approval would mean funding every approver's wallet before they could act. Signing off-chain and verifying on-chain means approvers need **zero balance** and only the treasury ever holds funds. One Arc-specific detail we handled carefully: **native gas accounting uses 18 decimals while the USDC ERC-20 uses 6.** Same asset, two scales, a factor of 10¹² apart. We branded them as distinct TypeScript types (`Usdc6`, `Native18`) so mixing them is a compile error rather than a runtime surprise. Every amount in the system is a `BigInt` count of base units — never a float. ## Other implementation notes - **Signatures must arrive sorted by recovered address, strictly ascending** — that makes counting one approval twice structurally impossible rather than something to check for, and it's cheaper than a seen-set on-chain. - **Approvals are keyed by the request, not the order**, because approvers sign before an order exists; `commitOrder` then writes under that same key so signature and commitment refer to the same thing. - **Approval flows are snapshotted onto each request at submit**, so editing org policy later can't retroactively move the bar a live request has to clear. - **The double-entry ledger posts automatically** on receipt, bill and payment. GR/IR clearing nets to zero only when what arrived and what was invoiced agree — the exception report is a balance, not a screen. - **PDFs are generated without a library** — the base-14 fonts are guaranteed present in every reader, so nothing is embedded and each document is 3–4KB, with no font or bundler configuration inside a serverless route. - **Prisma 7** moved connection URLs out of the schema and requires a driver adapter; the client is built lazily so a build needs no database, and cached so warm serverless instances reuse one pool.