Security

Threat model

What an attacker could try against a confidential payout round as built today, what stops each attempt, and the threat table for the planned operator service.

This page has two parts.

  1. The payout flow as built today. This runs on Stellar testnet. Each row names an attack and the mechanism in the current code that stops it, or says plainly that nothing stops it.
  2. The planned operator service. This is a design and has not been built. Its threat table is reproduced from docs/OPERATOR-DESIGN.md §7.

Part 1: the payout flow as built today

Who is involved

PartyHoldsWants to learn or prove
FunderThe lane accounts' spending and viewing keysProves the round total to a donor without revealing any single amount
RecipientTheir own confidential accountReceives an amount the public ledger does not show
Donor (or auditor)Their own disclosure keypair (r_R, P_R) and a fresh nonce νLearns the round total and checks that nothing was left out
ObserverPublic chain dataAnything they can read from the ledger

The funder is the party the donor does not trust. Most of the attacks below are attempts by the funder to make the disclosed total look different from what the chain records.

What the verifier takes from the funder

tally verify takes exactly three values from the funder's bundle: the proof, r_disc, and the sealed total v_tilde_disc. It works out everything else itself, from chain state or from the donor's own challenge (evidence README).

From the chainFrom the donor's own challengeFrom the funder
Lane set and window (round registry)P_R, νproof
Every transfer out of those lanes in that windowr_disc_x, r_disc_y
Each sender's and recipient's public viewing keyv_tilde_disc
addr_f, n_active

This boundary is the reason most of the attacks below fail. A funder who could supply a public input could prove a statement about a set of transfers it chose.

Attacks and what stops them

#AttackWhat the attacker triesWhat stops itSource
A1Withholding transfersThe funder proves a total over only some of the transfers sent from the declared lanesconfidential_transfer always emits a Transfer event with from and to topic-indexed. The verifier lists the round's transfers from those events itself. If one is withheld, the count from chain does not match the proof and verification failsTRUST-STATEMENT; evidence README
A2Cherry-picking lanesThe funder runs the round, then declares only the lanes that make the total look goodopen_round stamps its ledger from the contract, not from the caller. The lane set is immutable once written. The verifier reads the lane set and window from the registryTRUST-STATEMENT
A3Inflating the totalThe funder edits the sealed total in the bundleRaising v_tilde_disc by one makes verification fail with exit code 2. The published evidence includes this as a test anyone can runevidence README, "Try breaking it"
A4Counting one transfer twiceThe funder references the same event in two slots of the aggregate, which inflates the totalThe circuit cannot see duplicates, so the verifier rejects duplicate event referencesSDK-SAFETY-INVARIANTS §I2
A5Supplying keys from the bundleThe funder supplies sender or recipient public viewing keys of its choosingThe verifier resolves each PVK_A from the event's from and each PVK_B from its to, never from the bundleSDK-SAFETY-INVARIANTS §I2
A6Replaying a bundleThe funder reuses a valid bundle from an earlier challengeThe donor issues a fresh nonce ν for each challenge. A bundle replayed against a challenge it was not built for fails with exit code 2. This is also a published testevidence README, "Try breaking it"
A7Small aggregatesSomeone produces an "aggregate" over one or two transfers, which reveals the individual amountsThe circuit asserts n_active >= MIN_ACTIVE (default 5), so an undersized aggregate cannot be proved. n_active is a public input, so the donor sees the exact set size and can apply a stricter floor. The circuit family starts at n = 8SDK-SAFETY-INVARIANTS §I2
A8Stale evidenceA reviewer checks a round after its events have left the RPC window, gets an empty transfer set, and concludes the proof failedverify checks the RPC's oldestLedger through getHealth before listing transfers. If the round has aged out, it says so and exits 3, distinct from 2 (proof rejected) and 1 (could not verify). Published evidence states the ledger after which it stops being verifiableSDK-SAFETY-INVARIANTS §I4; evidence README
A9Undisclosable transfersA transfer is sent with a random ephemeral scalar r_e, so it can never be included in a proof. Nothing on chain shows thisInvariant I1 requires every transfer Tally originates to derive r_e as poseidon_with_domain(δ_eph, [vk, σ_E]), requires the transfer path to expose no override, and requires the auditor CLI to test disclosability per event instead of assuming it. Gap: the ported SDK's transfer witness builder still accepts an optional rE override. It defaults to the derived valueSDK-SAFETY-INVARIANTS §I1; ct/sdk/src/witness/transfer.ts
A10Version driftCircuits, contracts and SDK are built against different OpenZeppelin revisions, so keys or verification keys silently disagreeAll three are pinned to OpenZeppelin v0.9.0 (df602b6). CI rebuilds the circuits and fails if the pinned verification keys change. verify checks the verification key against the pinned artifactSDK-SAFETY-INVARIANTS §I3; ci.yml; evidence README
A11Over-disclosure to the donorThe donor asks for the funder's viewing key to check the totalThe viewing key is never shared. It would let the holder recompute every ephemeral scalar the funder ever used and open every amount retroactively. The donor generates their own keypair and receives a proof sealed to it that reveals only the aggregateREADME, "How the proof works"

What nothing stops today

These are outside the guarantee. The trust statement says so in its second sentence.

AttackWhy it is not stopped
Paying from undeclared accountsThe guarantee covers the declared lane accounts only. Transfers from other accounts the funder controls are outside the proof and cannot be detected from it
Self-dealingA funder can pay accounts it controls and prove a valid total. The proof establishes what amounts moved, not who controls the receiving accounts. Recipient identity is future work (roadmap)
Differencing across aggregatesRepeated aggregates over overlapping sets of transfers could be subtracted from each other. The operator design lists this as a residual risk that needs request-history checks, which are not designed yet (OPERATOR-DESIGN §7, T13)
Historical verificationAfter about 7 days the RPC no longer serves the round's events. No durable archive exists. See Known limits

What an observer sees

Amounts are encrypted on chain. Addresses stay visible: every confidential transfer publishes its sender and recipient. An observer can see that a lane paid a recipient. They cannot read how much. The aggregate proof is verified off-chain by the donor, so nothing on chain records that a disclosure happened (MEASUREMENTS.md).

Part 2: the planned operator service

The operator service is designed, not built. Nothing in this table exists in code. It is reproduced from docs/OPERATOR-DESIGN.md §7. See Operator design for the components it refers to.

The design's rule is that no single party, Tally included, can decrypt any amount, prove a clawback, or change a list on its own. t is the number of custodians needed to act (2 of 3 in the chosen design).

#ComponentAbuse or failureImpactMitigationResidual risk
T1Key sharest custodians colludeFull decryption for every account bound to that auditor_idIndependent custodians, one of whom must be neither tenant nor Tally; signed custody agreements; transparency log of every contributionCollusion of t cannot be detected cryptographically; deterrence is contractual only
T2Key sharesOne share stolenNone by itselfShares held in separate HSMs; proactive re-sharing invalidates stolen sharesA thief who collects t shares across epochs before a refresh
T3Distributed key generationBiased or backdoored key; key at or above the field modulus rUnusable or weak keyVerifiable DKG transcript published; range check; proof of possession publishedNone listed
T4Auditor registryAdmin registers a key nobody holds (no proof of possession on chain)Auditing silently blindedTally publishes the proof of possession; verifiers and tenants check it before trusting an auditor_idHolders who never check
T5TEE proving stepTEE compromise or side channel during key reconstructionk exposedReconstruction only for logged, approved requests; attestation published; short sessions; TEEs from different vendorsHardware-level attacks. This is the design's weakest point
T6Request workflowOver-broad scope; forged legal basisExcess disclosureEach custodian checks scope independently; tenant scope policy; requester identity verification; requests logged and, by default, the subject is notifiedApprovers who rubber-stamp
T7Request workflowRequester smuggles in out-of-scope eventsExcess disclosureCustodians resolve events themselves from at least two archives and never accept R_e from the requesterArchive collusion (see T10)
T8ContributionsA custodian returns a wrong partial resultWrong amountsDLEQ proof with each contribution; the combiner checks against the on-chain commitmentNone listed
T9Standing openingsThe opening store leaksCurrent balances of all bound accountsOpenings additively secret-shared; never held in the clear by one partyNone listed
T10ArchivesAn archive omits or forges eventsIncomplete scope or verificationAt least two independent archives; every opening checked against on-chain commitments; RPC cross-check within the retention windowAll archives colluding to omit
T11Disclosure verifierThe verifier takes inputs from the bundleForged totalsThe verifier resolves every input from chain. This rule already exists and is enforced in cli/tally.tsNone listed
T12Holder disclosuresA holder omits inbound eventsUnder-reported inflowsInbound totals labelled holder-asserted; completeness only through auditor-attested totalsNone listed
T13Aggregate privacyA small aggregate reveals individual amountsPer-recipient disclosureIn-circuit MIN_ACTIVE; n_active publicRepeated aggregates over overlapping sets (differencing). Needs request-history checks, not designed yet
T14OpenZeppelin listsPolicy signer key theftArbitrary freezes and unfreezes2-of-3 multisig; logged; stricter rules for loosening than for tighteningTheft of a quorum
T15SPP listsA blocklist update invalidates proofs in flightUser transactions failPublished batch schedule; user notificationEmergency updates still break proofs
T16SPP listsThe allowlist cannot remove entriesA removed subject is still a memberBlocklist insertion, disclosed to the tenantOn pools without a blocklist, removal is impossible
T17SPP Global View KeyThe view-key quorum is compromisedEvery future note readableSame custody as T1 and T2No rotation; the only remedy is a new pool and user migration
T18ClawbackAdmin and auditor colludeSeizure without due processSeparate signers; clawback requires a logged request through the same workflowTenant governance
T19Operator outageTally is unavailableRequests stall; lists frozenCustodians can decrypt without Tally (Tally is one of three); list contracts remain readableList updates pause
T20Upstream changeOpenZeppelin or SPP changes its cryptography (as v0.9.0 did)Silent mismatch; failed decryptionPinned revisions; conformance vectors in CI; coordinated upliftUpstream timing
T21RegulationThe operator is compelled to act outside policyDisclosure beyond the tenant's policyTally holds one share only, so compelling Tally alone cannot reach tCompulsion of t custodians in one jurisdiction; custodians spread across jurisdictions