Skip to content

feat: add soulbound donation receipts (#146) - #158

Open
neromtoobad wants to merge 1 commit into
OrbitChainLabs:mainfrom
neromtoobad:feat/campaign-receipt-nft
Open

feat: add soulbound donation receipts (#146)#158
neromtoobad wants to merge 1 commit into
OrbitChainLabs:mainfrom
neromtoobad:feat/campaign-receipt-nft

Conversation

@neromtoobad

Copy link
Copy Markdown
Contributor

Summary

Closes #146. Once a campaign is finalised — goal reached and final milestone released — each donor can claim a permanent, non-transferable receipt recording what they contributed.

campaign.is_finalised()            // -> bool, receipts claimable?
campaign.claim_receipt(donor)      // -> DonationReceipt { amount_donated, campaign_goal, minted_at, .. }
campaign.get_receipt(donor)        // -> Option<DonationReceipt>
campaign.receipt_balance(donor)    // -> 1 if held, else 0   (token-shaped)
campaign.receipt_total_supply()    // -> u32
campaign.receipt_transfer(..)      // -> panics: ReceiptNonTransferable

Three deviations from the issue text — and why

These were raised on the issue before I started; documented in the module header too. Happy to rework any of them.

1. Claim (pull), not auto-mint (push). The issue says "mint soulbound SAC tokens to donors" at finalise. That isn't implementable here: donors are stored under DataKey::DonorData(donor) with no list or index, and Soroban has no key enumeration — the contract cannot walk its donors. Even given a list, minting to every donor inside one transaction is an unbounded loop that would exceed resource limits on a large campaign. So each donor claims their own receipt: O(1), no enumeration, no gas cliff.

2. A soulbound record, not a SAC. A Stellar Asset Contract implements the standard token interface — transfer is built in and cannot be made to panic, so the acceptance criterion "soulbound enforcement (transfer panics)" is unreachable with a real SAC. The receipt is instead a donor-keyed record with token-shaped reads (balance, total_supply) and transfer / transfer_from / approve panicking with ReceiptNonTransferable. The guards are exposed deliberately rather than omitted: a missing function merely makes transfers impossible, whereas an explicit panic makes the intent legible on-chain and returns a precise error.

3. finalise is a condition, not an entrypoint. There's no finalise function in the contract, so is_finalised() computes it: raised_amount >= goal_amount AND final milestone == Released. Both halves matter — a campaign can hit its goal with milestones outstanding.

Acceptance criteria

  • Minting happy path — receipt carries the donor's total_donated (the issue's "metadata references the donation amount"), is readable afterwards, and updates balance/total_supply
  • Soulbound enforcement (transfer panics)transfer, transfer_from, approve all panic with #92, and a failed transfer leaves the receipt with its owner

Tests — 13 new

Area Tests
Finalise logic goal unmet → not finalised; last milestone open → not finalised; both → finalised
Mint happy path amount/goal/timestamp recorded; readable after; per-donor independence; balance 0 before claim
Soulbound transfer, transfer_from, approve panic; receipt survives a failed transfer
Guards claim-before-finalised (#90), non-donor (#51), double-claim (#91)
165 passed; 0 failed        (152 pre-existing + 13 new — no regressions)
cargo clippy <contracts> -- -D warnings   clean
cargo fmt --check                          clean

Fitting the codebase

New error codes go in a fresh 90s group (matching the existing 30s/40s/50s grouping); DonationReceipt mirrors DonorRecord; storage accessors follow set_donor/get_donor incl. TTL bumping; the event topic mirrors donation_received. Test snapshots are committed, consistent with the 138 already tracked.

One note for reviewers: test_double_claim_panics seeds the already-claimed state rather than calling claim_receipt twice — two require_auth() calls for one address in a single as_contract frame trip the auth mock ("frame is already authorized") before the guard is reached. test_claim_refund_already_claimed pre-seeds for the same reason.

Copy link
Copy Markdown
Contributor

Soulbound donation receipts are a brilliant UX touch. CI green, squashing in. ✅

Copy link
Copy Markdown
Contributor

After landing the recent batch, this branch now has merge conflicts with main. Could you rebase and push the fix-up? CI is clean. 🙏

@neromtoobad
neromtoobad force-pushed the feat/campaign-receipt-nft branch from 742627b to 8a5ea98 Compare July 20, 2026 15:33
@neromtoobad

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main and pushed @ibrahimmosouf-png — the conflict was a one-line import overlap with the #118 milestone-layout change. Contract clippy -D warnings and the full campaign test suite pass locally. Should be conflict-free and green.

@ibrahimmosouf-png

Copy link
Copy Markdown
Contributor

@neromtoobad please remove test snapshots

@neromtoobad
neromtoobad force-pushed the feat/campaign-receipt-nft branch from 8a5ea98 to adba446 Compare July 20, 2026 15:53
@neromtoobad

Copy link
Copy Markdown
Contributor Author

Done — all test-snapshot artifact changes are out of this diff; it's back to just the receipts work. (FYI: #182 removes the auto-generated campaign/test_snapshots/ folder repo-wide and gitignores it, replacing it with reviewed insta event snapshots per #115.)

@neromtoobad
neromtoobad force-pushed the feat/campaign-receipt-nft branch 2 times, most recently from 57f9e31 to 3dc6436 Compare July 21, 2026 05:55
@neromtoobad

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main @ibrahimmosouf-png. The conflict was an error-code + event collision with the circuit breaker (#183, now on main): both PRs claimed codes 90–92. I kept #183's AssetBlocked = 90 and renumbered the receipt errors to 91/92/93 (CampaignNotFinalised/ReceiptAlreadyClaimed/ReceiptNonTransferable), updating the should_panic expectations to match. Also merged event.rs to keep both the asset block/unblock and receipt_minted emitters. Full campaign suite + contract clippy green locally.

@ibrahimmosouf-png ibrahimmosouf-png left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please we still have test snapshots, remove them.

@ibrahimmosouf-png

Copy link
Copy Markdown
Contributor

@neromtoobad

@neromtoobad
neromtoobad force-pushed the feat/campaign-receipt-nft branch from 3dc6436 to 94c7c7c Compare July 23, 2026 12:04
@neromtoobad

Copy link
Copy Markdown
Contributor Author

Done @ibrahimmosouf-png — apologies, the big rebase across #185 swept the regenerated test_snapshots artifacts back into the diff. All 19 snapshot paths are now reverted to main's state; the diff is purely the receipts work again (receipt.rs, types, storage, event, lib + tests). Campaign suite green locally.

@neromtoobad
neromtoobad force-pushed the feat/campaign-receipt-nft branch from 94c7c7c to 983641d Compare July 23, 2026 12:05
Rebuilt cleanly on current main (post OrbitChainLabs#180/OrbitChainLabs#186-OrbitChainLabs#190/OrbitChainLabs#163): receipt module
+ entrypoints on the decomposed crate, receipt errors at 91/92/93 (90 is
the merged circuit breaker's AssetBlocked), ReceiptData/ReceiptCount keys
appended at the DataKey tail after CachedReport.

Once a campaign is finalised — goal reached and final milestone released —
each donor can claim a permanent, non-transferable receipt attesting to
their cumulative contribution: claim_receipt, is_finalised, get_receipt,
receipt_balance, receipt_total_supply, and explicit soulbound guards
(transfer/transfer_from/approve panic with ReceiptNonTransferable).
receipt_minted event mirrors donation_received's topic shape for
per-campaign indexer subscriptions.

13 receipt tests green; full campaign suite passes.

Closes OrbitChainLabs#146
@neromtoobad
neromtoobad force-pushed the feat/campaign-receipt-nft branch from 983641d to 54665a4 Compare July 23, 2026 12:09
@neromtoobad

Copy link
Copy Markdown
Contributor Author

Properly fixed now @ibrahimmosouf-png — my earlier pushes were checking the wrong diff and snapshot artifacts kept surviving in the PR. I rebuilt the branch from scratch on current main: the PR diff is now exactly 6 source files (receipt.rs + tests, and the types/storage/event/lib wiring) with zero test_snapshots paths. Also picked up your merged #180 as base and re-verified the receipt error codes (91/92/93) still clear of the merged circuit breaker's 90. 189 campaign tests green. Apologies for the back-and-forth on this one.

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.

Implement campaign NFT receipts on completion

2 participants