Skip to content

Events 2.0.0 and profile 1.2.1: audit hardening (includes #120 and #165) - #166

Merged
0xdevcollins merged 21 commits into
testnetfrom
feat/grant-hardening
Oct 3, 2026
Merged

0xdevcollins merged 21 commits into
testnetfrom
feat/grant-hardening

Conversation

@0xdevcollins

Copy link
Copy Markdown
Collaborator

This brings testnet up to date with what is deployed and with the build going to audit. It contains:

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

  • Grants: a roster replaces the winner-record scan, so payout cost no longer grows with the grant. The owner can close a milestone unpaid with forfeit_milestone, and start_cancel refuses a grant while any award is still owed.
  • Refunds: a refund the account cannot receive is set aside for claim_refund, instead of blocking every other refund.
  • Managers: the owner can take management back until the first award.
  • Crowdfunding: payouts are co-signed by a release validator, appointed in two steps and removable in one. The admin co-signs while none is appointed.
  • Caps: sized to mainnet's per-transaction limits: 20 awards per call, 40 per grant, 15 refunds per batch, 90-entry pages. Reputation bumps are capped at 100, and claim windows count only time the contract was not paused.
  • Upgrades: the timelock is back to 17,280 ledgers on mainnet builds. migrate refuses with MigrationIncomplete (97) until every record is converted.
  • Removed: profile slash_reputation, leftover storage keys, unused errors and dead code. Retired error codes are listed in errors.rs so they are never reused.
  • Tooling: soroban-sdk 28.0.0 and Stellar CLI 28.1.0.

Verification

  • 386 tests pass: 326 events, 60 profile. Clippy is clean with warnings treated as errors.
  • scripts/test-storage-compat.sh replays the real mainnet upgrade (1.7.0 to 2.0.0) against storage captured from mainnet ledger 64,747,647.
  • The grant scenario suite runs every cap at its maximum within mainnet's limits.
  • A Scout re-scan of the 2.0.0 source finds no critical issues; details are in docs/scout-audit-report.md.
  • The threat model is now version 1.2: 76 threats, of which 62 are mitigated, 10 accepted, 4 retired and none open.

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 in docs/threat-model.md, section 4.4.

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.
@almanax-ai

almanax-ai Bot commented Oct 3, 2026

Copy link
Copy Markdown

Plan expired

Your 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant