Repository navigation
Events 2.0.0 and profile 1.2.1: audit hardening (includes #120 and #165) - #166
Merged
Merged
Conversation
Allocation moves out of create_event and into select_winners. An event now
holds a pool; each selection names the amount it spends from it. The prize
table published at create is stored as per-position minimums, so an award
may exceed what was advertised but never fall below it.
Percentages are gone, and with them the 1% minimum prize, the whole-percent
constraint, and the rounding residual that was dumped on position 1. A
top-up now has exactly one consequence: the pool is larger. It no longer
silently reprices every prize before the first selection, nor becomes inert
after it.
EventOwedTotal reserves awarded-but-unclaimed prizes. remaining_escrow only
drops at claim time, so without the reservation a second selection would see
funds an earlier winner is still entitled to and could promise them twice,
leaving the second winner unable to claim. Cancelling releases the
reservation rather than withholding it, since both claim paths require
Active and withheld funds would otherwise strand with no path out.
Grants derive each milestone payment from the awarded amount instead of a
share of total_budget, so a grant top-up now reaches its milestones.
Crowdfunding drops its {1: 100} check; its milestone math never read the
distribution.
Closes #107, #108, #109, #110, #111, #112, #113, #114
The record layout change is not backward compatible: winner_distribution and prize_floors differ in both field name and value type, so a row written before 1.7.0 cannot be decoded by the current struct at all. migrate() reads each stored event through the legacy shape and rewrites it, converting percentages with total_budget * percent / 100, which is exactly what each position would have been paid. This is only affordable because mainnet holds two events, both Completed with zero remaining escrow, so neither can re-enter select_winners. The same rewrite after a real campaign funds escrow would be a production migration on the money path. Bounded by the id counter rather than scanning blindly; the row cap is a backstop against a corrupt counter, not an expected limit. A fresh deployment has no rows and the pass is a no-op. Closes #115, #116
One wallet could hold exactly one submission per event, because the storage key was (event, applicant). That is fine for a bounty asking for one piece of work and wrong for any event that wants two different things from the same person. Submissions now key on (event, applicant, slot). The contract assigns the slot no meaning. It is a caller-chosen u32, so whatever separates entries off chain (a track, a category, a round) needs no further contract change to express. Re-submitting to an occupied slot still updates in place and keeps the original timestamp; a fresh slot appends. A per-applicant counter keeps "has this wallet submitted at all" O(1), which is what gates application withdrawal. Without it that gate would have to scan slots, and it would have silently regressed to checking slot 0 only. The old key is kept in DataKey as read-only so migrate can move each historical row into slot 0 and drop it. Skipping that would leave the two settled mainnet events' submissions occupying storage with no read path able to reach them: the applicant index bounds the scan, and mainnet holds six applicants across both events. Closes #117
Five fixes, four of them in the migration, which had no test exercising a pre-upgrade state and so passed while being wrong. Grants selected before the upgrade stored amount 0 on the anchor winner row, because the payout used to come from the percentage distribution at claim time. claim_milestone now reads that amount, so those grants would have reverted on every milestone claim with no way to re-select and no exit but cancelling. migrate rewrites the anchor to the position's floor, which is what the old formula would have produced. Submission migration was bounded by the applicant index, which only apply_to_bounty populates and only for the bounty pillar. Hackathon submitters never appear there, so their rows would have been left unreachable behind the re-key, and has_any_submission would have stopped blocking application withdrawal for someone still holding one. Since those submitters cannot be enumerated on chain, reads now fall back to the legacy key as slot 0 and the first write folds the row into the slotted layout. The migration row cap now fails closed. It stamped the version even when the id range exceeded it, and migrate is one-shot, so the remainder would have been stranded in a layout the current struct cannot decode. Submitted and SubmissionWithdrawn carry the slot. Without it a withdrawal of slot 1 is indistinguishable from slot 0 on the wire, and the subscribers resolve anchors by (event, applicant). claim_prize and claim_milestone subtract from the owed total with a checked op instead of clamping at zero, so a drifted reservation fails loudly rather than silently under-reserving the next selection.
…tate The migration assumed it would only ever meet pre-1.7.0 rows. Three ways that failed. Decoding every stored event as the legacy struct aborted the whole invocation when it met a row already in the current layout, because a missing field escalates to a host error rather than a catchable one. That is the documented testnet sequence: redeploy fresh, run the smoke script, then migrate. A contracttype struct is stored as a map keyed by field name, so the pass now identifies the old layout by the field only it carries and leaves current rows alone. The rewrite was gated on the stored version matching INITIAL_VERSION exactly, while propose_upgrade accepts any non-empty string. Proposing as "v1.7.0" would have skipped the rewrite, stamped the marker anyway, and left every legacy event undecodable with migrate refused thereafter. The pass is idempotent now, so it runs unconditionally and the string is irrelevant. Folding an unmigrated submission into slot 0 set the per-applicant counter to 1 only when it was zero, which undercounts an applicant who already holds other slots. After one withdrawal has_any_submission would read false while a slot was still occupied, unlocking application withdrawal for someone holding a live submission. It increments instead.
Measured against the host rather than assumed: an invocation may touch 100 ledger entries and write 50. The migration was built as though it had the whole ledger. migrate walked every applicant of every event looking for legacy submission rows. Per-event applicant caps were removed deliberately in #104, so one popular bounty is enough to push the pass past the footprint limit, and because the call reverts nothing is stamped, so it can be retried but never succeed. Every pre-1.7.0 event would then stay undecodable. The scan is removed rather than budgeted. Since reads already fall back to the legacy key as slot 0 and the first write folds the row in, moving rows eagerly was only a storage tidy-up, and it was the one unbounded part of the pass. What remains is load-bearing and bounded: the record rewrite, and the winner-amount rewrite for Multi events. MAX_ROWS drops from 256 to 16 for the same reason. 256 events could never have fit in one transaction, so the old cap described an impossible run. A deployment that trips the new one aborts before stamping and needs a paged entrypoint rather than a one-shot pass. get_legacy_submission now extends the entry's TTL like every other accessor in that file. It stopped being a one-shot migration source and became the only path reaching a hackathon submission, so without the touch those rows archive and the submission silently disappears.
Proven necessary rather than theorised. Testnet was upgraded to 1.7.0 at ledger 4199077 and migrate() aborted with EventIdOverflow: the deployment holds over a hundred events against a one-shot pass capped at 16, so every event created before the upgrade is undecodable and there is no way to finish. Mainnet's two events only postpone the same wall. The cap was never the real constraint. An invocation may touch 100 ledger entries and write 50, so no single transaction can convert a deployment with history, whatever the cap says. migrate_events(max_events) now converts a bounded slice and advances a stored cursor, returning how many remain. An operator loops until it reports zero. migrate() refuses to stamp until the cursor reaches the end, which is what stops a partial pass being sealed in — it is one-shot, so an early stamp would strand the remainder permanently. Per-call work is capped at 8 events regardless of the argument, since each costs a record read and write and a Multi event adds a read and write per winner. Asking for 0 means "use the ceiling" rather than "do nothing". Reuses EventIdOverflow for "events remain": contracterror is at the 50-case cap. MAX_ROWS is gone; the cursor is the gate now.
H6 (audit 2026-06) mandated 17_280 ledgers, about a day, between propose_upgrade and apply_upgrade. Set to 0 on both contracts so the 1.7.0 rollout can iterate without a day's wait per attempt. Defensible now and not later: mainnet holds no escrow at all today, both events Completed with remaining_escrow 0, so the control currently protects nothing. That stops being true the moment a campaign funds. With no window a compromised 2-of-3 admin can propose and apply a wasm swap in a single sequence, and cancel_pending_upgrade never gets a chance to fire. The cfg split is retained rather than collapsed so restoring is a single-value edit per contract. Both timelock tests now assert the zero-window behaviour and carry the restore instruction, so flipping the constant back fails them loudly rather than passing silently. Tracked in BACKLOG under P1: restore before the first funded mainnet campaign. The pending third-party audit will flag this if it is still 0.
1.7.0 reserves awarded-but-unclaimed prizes because remaining_escrow only drops at claim time, so a second selection would otherwise see funds an earlier winner is still entitled to. The migration rebuilt the award amounts but never wrote that reservation, so a migrated event reported nothing owed while a winner was still owed everything, and the next batch could promise the same money twice. Outstanding is reconstructed the way the live path maintains it: award amounts not yet paid out, less whatever milestone rows already paid. A Single award marks its anchor paid_at on claim; a grant leaves the anchor open and appends one paid row per milestone. The suite stayed green through this because every migration test inspects the rewritten record and none replays a selection afterwards. The new test does, and fails without the fix.
Wasm b87365c1 (62,110 bytes) with the unsigned propose_upgrade envelope, the pre-flight to re-run before signing, and the propose -> apply -> migrate_events -> migrate -> backend sequence. The same bytes are live on testnet and passed all 22 smoke scripts, so what mainnet would run has now run on a chain rather than only in tests. Signing needs 2 of the 3 multisig keys, none of which are on the build machine. The open items a signer should weigh are listed rather than buried: the timelock is 0 so propose and apply collapse into one step, the audit P0 is open, and migrate() cannot be re-run once stamped.
Supersedes the events-only proposal doc. Profile turned out to be on its own version line, 1.1.0 on chain against 1.2.0 in source, carrying the overflow fixes and the non-malleable op_id work rather than anything from 1.7.0. Its public interface is unchanged, so the ordering is convention rather than a dependency, and its migrate() is a version stamp with an empty dispatch. Two traps are called out rather than left to be discovered. Both prepared envelopes carry the same sequence number, so only one can ever be submitted and the rest have to be built after each confirmation. And the point of no return is migrate_events, not apply_upgrade: once the records are rewritten, 1.6.0 code cannot decode them, so reverting the wasm after that bricks reads.
--build-only on its own emits fee 100 and no SorobanTransactionData, so the envelopes in the previous revision carried no footprint and no resource fee and would have been rejected on submit. The profile propose goes from fee 100 to 318,317 stroops once simulated. Every build in the runbook now pipes through stellar tx simulate, and the events propose is a recipe rather than a pinned envelope, since it cannot be built until the profile steps have moved the sequence.
BREAKING: removes apply_to_bounty, withdraw_application, submit, withdraw_submission and the applicant/submission read accessors. Nothing on chain ever read them. select_winners and claim_prize check the award record and the caller's signature, never the applicant index or a submission anchor, so the only consumer of an application was submit, and the only consumer of a submission was withdraw_submission. A closed loop, and a permissionless one: anyone could write an entry for any active event without going through Boundless, which is what made the record worthless as evidence. Applying and submitting are product facts and now live in Postgres. Two properties improve on the way out. No permissionless entrypoint calls the profile contract any more, so the derived child op_ids of claim_prize and claim_milestone can no longer be burned by a stranger (#129 keeps its fix, but loses its cheap exploit path). And grant submissions stop being a dead branch: submit demanded an application that apply_to_bounty refused to create for a grant, so the call could only ever fail (#132). Storage keys and error discriminants are retired in place rather than deleted, so the remaining variants keep their positions and rows left on live events stay identifiable until they archive. Version goes to 2.0.0 on both the contractmeta and Cargo (which also closes the #142 mismatch): removing entrypoints is breaking, and the upgrade is proposed with that version string. docs/threat-model.md, BACKLOG.md and the Scout report are not touched here; they are checked out dirty in a stash and their STRIDE rows need the same pass.
Restores the working tree that was sitting in a stash: the STRIDE model rewritten to the SDF's four-question structure with 72 threats, trust boundaries and figures, the Scout re-run assessments, and the two backlog entries recording what the 2026-09-11 mainnet read found. Content is unchanged from the parked version. It is committed here so it stops living in a stash, where a `migrate dev` reset came close to taking it once already.
Three threats are void once the on-chain participation records go: Tamp.3 and Tamp.17 describe overwriting a submission that no longer exists, and DoS.13 was the dead grant-submission branch, whose second candidate remediation is exactly what 2.0.0 did. Two more narrow. Tamp.14 keeps its remediation, but with apply_to_bounty gone no permissionless entrypoint calls the profile contract, so burning a derived child marker now needs a privileged signature. DoS.7 loses one of its two coupled callers, leaving claim_milestone. Open items drop from ten to eight. The flow keeps its step numbering, so every "(Step n)" reference in the threat tables still resolves; steps 5 and 6 say what they were and that they are gone. Appendix A strikes the removed entrypoints rather than deleting the rows, matching how the retired storage keys and error discriminants are handled in the code.
Closes every open item in the threat model, and the issues found by running each entry point at mainnet's per-transaction limits. - Grants: a roster and per-recipient progress replace the winner-row walk; the owner closes a milestone unpaid with forfeit_milestone; start_cancel refuses a grant while any award is owed (AwardsOutstanding). - A refund the crank cannot deliver is held for claim_refund instead of stalling the batch. - The owner can reclaim management until the first award. - Crowdfunding releases are co-signed by a release validator appointed in two steps and removable in one; the admin co-signs until one is appointed. - Caps sized to mainnet limits: 20 awards per call, 40 per grant, 15 refunds per batch, 90-entry pages. Reputation bumps capped at 100 on both contracts. Claim windows count only unpaused time. - Upgrade timelock restored to 17,280 ledgers on mainnet builds. - migrate_events converts the record layout only; migrate refuses with MigrationIncomplete (97) until every record is converted. - Removed: profile slash_reputation, the pre-roster grant path, legacy op-marker lookups, dead helpers, retired storage keys and unused errors. Retired error codes are listed in errors.rs and never reused. - soroban-sdk 28.0.0; profile 1.2.1. - Grant scenario suite (randomized runs, limits, a refusing token) and a storage-compatibility replay of the mainnet 1.7.0 to 2.0.0 upgrade against a captured mainnet ledger.
…pgrade in CI - deploy_mainnet.sh takes the version as an argument and checks it against the wasm's contractmeta, requires CLI 28.1.0, adds prepare-migrate-events-page, and refuses to unpause an upgrade that has not been migrated. Guard tests updated to match. - test-sdk27-compat.sh becomes test-storage-compat.sh: it builds the release wasm and replays the mainnet upgrade against captured storage. - CI drops the obsolete fixture-hash gate and adds clippy with warnings as errors. - verify-multisig.sh checks signer type and the signer roster; verify-fee-trustline.sh runs before a token is whitelisted. - Testnet grant scenario runner (scripts/testnet). - Remove the dead pre-split deploy script and the spent 1.6.0 signing envelopes; signing/ is now ignored.
- Threat model 1.2: 76 threats (62 mitigated, 10 accepted, 4 retired, none open) in plainer language, retired threats in their own table, and a pending-deployment section for the mainnet upgrade. - Scout re-scan of the 2.0.0 source (0.3.17 on SDK 28): no critical findings; the one real medium (Tamp.19) is fixed. - New upgrade runbook with the mainnet 1.7.0 to 2.0.0 steps, a storage compatibility guide, and docs/history for finished procedures. - One deploy doc; the multisig preflight is folded into the multisig guide. - Drop credit and on-chain participation leftovers and fix the mainnet USDC address in the deploy docs.
Plan expiredYour subscription has expired. Please renew your subscription to continue using CI/CD integration and other features. |
rustc embeds source paths for panic locations, and by default they include the builder's home directory and checkout path. The mainnet 1.7.0 events build carries /Users/<name>/.cargo and .rustup paths, so a CI build of the same commit on Linux produced a different hash. scripts/build-release.sh runs `stellar contract build --locked` with the cargo home, the rust-src tree and the checkout remapped to fixed prefixes, then fails if any machine path is left in the wasm. The same commit now builds to the same hash from a different checkout and cargo home. CI, the deploy scripts, the storage replay and the runbooks use it.
Remapping paths made builds reproducible within one platform, but a macOS arm64 build still differs from CI's Linux x86-64 build of the same commit: cargo folds the host triple into the hashes behind symbol names, so the linker orders identical functions differently. scripts/build-release-linux.sh runs build-release.sh in a digest-pinned rust:1.93.0 image with the checksummed CLI 28.1.0 tarball CI uses. From a Mac it reproduces CI's hashes for this tree exactly (events 56bd903a, profile 8a8a1208). deploy_mainnet.sh and the upgrade runbook use it, and the runbook now checks the hash against CI's build log. Also records the final testnet run (317 of 317 checks against events b63afc1f and profile 09110572) and, in the threat model, that the mainnet 1.7.0 source reproduces both live hashes with CLI 27.0.0 on the machine that built them.
This was referenced Oct 3, 2026
This was referenced Oct 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This brings
testnetup to date with what is deployed and with the build going to audit. It contains:b87365c1…and6b480492…, as pinned in that PR's runbook).Merge with a merge commit, not a squash, so #120 is recorded as merged. #165, #121 and #93 are being closed in favour of this PR.
Contracts
forfeit_milestone, andstart_cancelrefuses a grant while any award is still owed.claim_refund, instead of blocking every other refund.migraterefuses withMigrationIncomplete(97) until every record is converted.slash_reputation, leftover storage keys, unused errors and dead code. Retired error codes are listed inerrors.rsso they are never reused.Verification
scripts/test-storage-compat.shreplays the real mainnet upgrade (1.7.0 to 2.0.0) against storage captured from mainnet ledger 64,747,647.docs/scout-audit-report.md.Not in this PR
Mainnet is untouched. The upgrade steps are in
docs/upgrade-runbook.md, section 8. The backend changes that must ship before that upgrade are listed indocs/threat-model.md, section 4.4.