Cross-chain private payroll on Starknet.
A company funds a payroll run once, on Starknet, using the STRK20 privacy pool. Recipients claim into a wallet on Starknet, an EVM chain, or Solana, with no on-chain link between payer and recipient, and no centralized service holding the payment list.
Built for the STRK20 Private Sprint (Starknet, 14–31 Aug 2026).
🚀 Live at stableroll.vercel.app
A payroll run funded and claimed in the open on a public ledger leaks a company's entire headcount, compensation bands, and org structure to anyone watching the chain. StableRoll routes funding and claiming through the STRK20 privacy pool so the run's existence and its aggregate numbers are public (so recipients and auditors can verify the run is fully funded), but which recipient got which payment is not.
Do not read this as "fully private". An on-chain observer of the Payroll contract and the pool can still see real information:
| Visible to any on-chain observer | Never visible on-chain |
|---|---|
That a payroll run exists (run_id) |
Which recipient corresponds to which commitment |
The run's expected_count and expected_total |
The amount a specific identity was paid |
Running totals: funded_count, paid_count, total_committed, total_paid |
The link between the payer and the run (the pool is always the caller; RunInfo stores no payer address) |
| That a given commitment was claimed, and the claim transaction itself | Which claim transaction belongs to which commitment/recipient, beyond what the commitment hash itself reveals |
The token used and whether the run is closed |
Recipient identities across claims (no address reuse is required, and none is enforced) |
The completeness guarantee (is_complete) is a public, verifiable property:
that a run was funded exactly as promised and every promised recipient was
paid, without revealing who those recipients are.
Every row above is mapped to its real source (contract fields, tests) in
docs/verification-guide.md; check that
before taking this table's word for it.
This repo is mid-build. Only claim the chains and legs that are real:
| Component | Status |
|---|---|
contracts/payroll: Cairo contract, privacy_invoke-driven run accounting, run-ownership authorization |
Done and tested (snforge test, CI-enforced) |
| Starknet → Starknet fund + claim, via the pool | Done and tested locally. Not yet exercised against live Sepolia or mainnet infrastructure |
| EVM claim leg (privacy-bridge) | Wired against the real API (cashOut, see docs/evm-claim-coverage.md). Not yet exercised against live testnet infrastructure |
| Solana claim leg (NEAR Intents) | Connector implemented against the real 1-Click API (see docs/solana-claim-coverage.md). Route verified live: pinned asset IDs and a dry-run quote are re-checked by npm run test:liquidity. The end-to-end claim is not exercised: NEAR Intents has no testnet, so it needs mainnet funds and human sign-off. See issue #37 |
| Waku recipient notification | Done and tested end-to-end against the live Waku test fleet (notify/). Sent from openAndFundSingleCommitment after a successful FundCommitment (see integration/src/sepolia-run.ts), and independently by integration/src/commitment-listener.ts polling CommitmentFunded for any commitment registered in the outbox ahead of time, so a commitment funded through a different path than sepolia-run.ts still gets notified once the chain confirms it (see docs/adr-commitment-funded-listener.md). The secret still has to reach the outbox from whoever funds the commitment; this is not a purely chain-driven notification |
Frontend app shell (frontend/) |
Done: /admin and /claim/[secret] routes exist and render without credentials. Superseded by the row below for actual behavior: both routes now carry real logic (Cavos sign-in, the dual-approval gate, Waku pending-claim discovery), not the placeholder shell this row originally described |
| Cavos payer/recipient UX | Partial: sign-in wired against the real @cavos/kit v0.1.11 API on /admin and /claim/[secret], dual-approval gate implemented and unit-tested. Submission is not wired: privacy_invoke accepts only the pool as caller, and that path needs a mainnet proving service that is not published. Waku pending-claim discovery now read by the page: /claim/[secret] queries Waku Store for a notification already sent and subscribes via Filter for one sent while it is open, reusing notify/'s derivation rather than a copy. Proven by a live-fleet round trip; an empty result is rendered as normal, not an error, since Store retention is finite |
| Mainnet eligibility transactions | Done: 3 recorded in strk20.json and verified on-chain (npm run verify:eligibility). See docs/mainnet-eligibility.md |
Payroll mainnet deployment |
Deployed to SN_MAIN, address recorded in strk20.json, but that class predates the on-chain dual-approval quorum from #31; a fresh declare and deploy is scoped in issue #41 and not yet done. Deployed only either way; no run has been submitted through it yet, see issue #34. See docs/mainnet-eligibility.md and the "Known gaps" section of docs/verification-guide.md |
Check the repo's GitHub issues for what's actively in progress; treat that tracker, not this README, as the up-to-date source of truth on scope.
Generated from diagrams/definitions/, not drawn by hand. The ASCII diagram
this replaced had already drifted (it omitted run ownership and still labelled
the EVM and Solana legs "planned"). Do not edit the SVG; change the typed
spec and run npm run generate in diagrams/. The run state machine and
claim-routing tree live in ARCHITECTURE.md.
contracts/payroll: the only component that ever touches recipient funds. Called exclusively by the privacy pool'sInvokeExternal; seeCLAUDE.md§6 for the accounting invariants it enforces (fixedexpected_count/expected_total, exact-final-commitment closing, run ownership proven by secret rather than address).integration: TypeScript tests and helpers driving the pool + Payroll contract via the privacy SDK.notify: Waku ECIES key/topic derivation and encrypted claim notifications, keyed off the same commitment secret as the on-chain claim, never a Starknet address (see the package'stopics.ts). Called fromintegration/src/sepolia-run.tsafter each successfulFundCommitment, and fromintegration/src/commitment-listener.tsfor commitments funded through any other path (seedocs/adr-commitment-funded-listener.md).integration/depends on it viafile:../notify(seedocs/adr-notify-package-boundary.md).integration/src/near-intents-connector.ts: the Solana claim leg, quote → deposit-notify → poll, against NEAR Intents' 1-Click API. It sits downstream of a Starknet claim and never touches custody or the privacy-critical accounting above.frontend: Next.js app with/adminand/claim/[secret]. Cavos provides seed-phrase-free sign-in on the Starknet side only; EVM and Solana recipients never see it. The separation-of-duties guarantee is enforced on-chain inPayrollitself (ApproveRun,QUORUM_NOT_MET; seedocs/adr-dual-approval-quorum.md).src/lib/quorum.tspredates that and is now a UX convenience layered on top of the contract invariant, not the only thing enforcing it, asfrontend/README.mdexplains.
| Dependency | Role | License |
|---|---|---|
| STRK20 Privacy Pool, Escrow pattern, Privacy Bridge | Core privacy primitive this repo builds on (StarkWare) | Apache-2.0 |
| Cavos | Starknet-side payer/recipient UX only, never custody, never a cross-chain leg | Verified directly: cavos-account is MIT (LICENSE in-repo) and @cavos/kit v0.1.11 declares MIT in its npm metadata; those two are what this repo depends on. Most other Cavos repositories carry no declared license; treat them as all-rights-reserved until Cavos states otherwise. |
| Waku | Recipient notification transport only, no custody, no privacy-critical logic depends on it | Apache-2.0 / MIT (dual, per Waku project) |
Requires asdf with the scarb and starknet-foundry
plugins.
asdf install # reads .tool-versions: scarb 2.17.0, starknet-foundry 0.63.0cd contracts/payroll
scarb build
snforge testcd integration
npm install
npm run test:offlinecd notify
npm install
npm run test:offline # deterministic derivation tests, no network
npm test # full suite, talks to the live Waku test fleet, no credentials neededNo environment variables: see notify/.env.example.
Requires Graphviz (dot on PATH). It is a local
and CI dependency, not pinned in .tool-versions.
brew install graphviz # macOS
# apt-get install graphviz # Debian/Ubuntu
cd diagrams
npm ci
npm run generate # writes diagrams/out/<name>.{dot,svg}CI fails the PR if a typed spec changed without regenerating the committed
.dot files. See ARCHITECTURE.md.
cd frontend
npm install
npm run dev # serves /admin and /claim/[secret], no credentials neededCavos sign-in is wired on /admin and /claim/[secret], behind the
dual-approval gate. Both pages render a labelled unconfigured state without
NEXT_PUBLIC_CAVOS_APP_ID and NEXT_PUBLIC_CAVOS_APP_SALT, so npm run dev
and CI work with zero credentials. Submitting a run is deliberately inert;
see src/lib/payroll-call.ts for why. See
frontend/.env.example for every variable; copy it
to .env.local to configure Cavos.
@starkware-libs/starknet-privacy-sdk ships on GitHub Packages and is
declared as an optionalDependency specifically so npm install succeeds
without a token: only the SDK-dependent tests are skipped/fail without one.
npm config set //npm.pkg.github.com/:_authToken <TOKEN> # needs read:packages
cd integration
cp .env.example .env # fill in; see integration/.env.example for what each var is and where it comes from
npm testCI (.github/workflows/ci.yml) runs only the tokenless path on every PR, by
design; see CLAUDE.md §8.
If you hit a build/toolchain error, check CLAUDE.md §3 first: its
error-to-cause map covers every trap this repo's toolchain has actually
produced, and the fix is almost never what the error text suggests.
Every command above, plus each suite's current pass count, is pinned in
docs/verification-guide.md; if a command
here and that guide disagree, trust what the command actually prints.
A ~93-second product demo (Remotion composition Demo at media/demo/) is
recorded in strk20.json demo_video and hosted as a
GitHub Release asset.
It uses the generated architecture SVGs and a real snforge test capture.
Mainnet tx hashes are omitted until issue #2 fills strk20.json transactions.
Every claim it makes also has its own row in
docs/verification-guide.md: the video is a
fast path through that guide, not a substitute source of truth for it.
Recorded in strk20.json and explained in
docs/mainnet-eligibility.md, which also gives
the route to bank them and the rules that apply. Each hash is also listed in
docs/verification-guide.md's mainnet-transactions
table, alongside the specific claim it substantiates.
The eligibility floor is met: three mainnet transactions are recorded, each
verified on-chain as successful and carrying an event from the pool. They
establish eligibility only: they were made from a privacy-enabled wallet, not
through StableRoll's own code. Payroll itself is deployed to SN_MAIN, but
that deployed class predates the on-chain dual-approval quorum (issue #41),
so do not read the deployment as feature-complete; see the "Known gaps"
section of docs/verification-guide.md. That state is machine-checked rather
than tracked by hand:
cd integration && npm run verify:eligibilityIt passes only when three distinct hashes are recorded and each one is confirmed on mainnet as a successful transaction that emitted an event from the pool. It is not part of CI: it needs a public RPC this repo does not control.
Apache-2.0; see LICENSE, matching the reference contracts this
project extends.
For contributor and toolchain context (pinned versions, the full
error-to-cause map, and the rules this repo is built under), read
CLAUDE.md before opening a PR.