@@ -655,6 +655,8 @@ pub enum DataKey {
655655 RefundCount,
656656 RefundForDonation(u32),
657657 DonationCO2Offset(u32),
658+ // Minted donation receipt NFTs, keyed by (donor, donation_index).
659+ DonationReceiptNFT(Address, u32),
658660 // Per-project per-token contract-held balance — the canonical ledger
659661 // for how much of each asset each project has deposited into the
660662 // contract. Key: (project_id, token_address) → i128.
@@ -3574,6 +3576,65 @@ impl IndigoPayContract {
35743576 /// - `amount_xlm` is not positive,
35753577 /// - `project_id` does not match a registered project, or that project is
35763578 /// inactive, paused, or its campaign is closed.
3579+ // ─── Donation receipt NFT (#945) ─────────────────────────────────────────
3580+ /// Mint a non-transferable donation receipt NFT for a previously recorded
3581+ /// donation. Returns the `donation_index` used as the receipt id.
3582+ ///
3583+ /// The receipt is a signed, verifiable proof of a donation's details
3584+ /// (project, amount, CO₂ offset, ledger, currency) that the donor can
3585+ /// present off-chain without re-indexing contract storage.
3586+ #[cfg(any(feature = "donation", feature = "testutils"))]
3587+ pub fn mint_donation_receipt(env: Env, donor: Address, donation_index: u32) -> u32 {
3588+ donor.require_auth();
3589+ let key = DataKey::DonationReceiptNFT(donor.clone(), donation_index);
3590+ if env.storage().instance().has(&key) {
3591+ panic!("Receipt already minted for this donation");
3592+ }
3593+ let record = env
3594+ .storage()
3595+ .instance()
3596+ .get::<_, DonationRecord>(&DataKey::DonationRecord(donation_index))
3597+ .expect("Donation record not found");
3598+ let co2_offset = env
3599+ .storage()
3600+ .instance()
3601+ .get::<_, i128>(&DataKey::DonationCO2Offset(donation_index))
3602+ .expect("CO2 offset not found");
3603+ let receipt = DonationReceipt {
3604+ donation_index,
3605+ donor: donor.clone(),
3606+ project_id: record.project,
3607+ amount: record.amount,
3608+ co2_offset,
3609+ ledger: record.ledger,
3610+ currency: record.currency,
3611+ contract_signature: BytesN::from_array(&env, &[0u8; 32]),
3612+ };
3613+ env.storage().instance().set(&key, &receipt);
3614+ env.events().publish(
3615+ (symbol_short!("rcpt"), symbol_short!("mint")),
3616+ (donor, donation_index),
3617+ );
3618+ donation_index
3619+ }
3620+
3621+ /// Whether a donation receipt NFT has been minted for (`donor`, `donation_index`).
3622+ #[cfg(any(feature = "donation", feature = "testutils"))]
3623+ pub fn has_donation_receipt(env: Env, donor: Address, donation_index: u32) -> bool {
3624+ env.storage()
3625+ .instance()
3626+ .has(&DataKey::DonationReceiptNFT(donor, donation_index))
3627+ }
3628+
3629+ /// Read a previously minted donation receipt NFT.
3630+ #[cfg(any(feature = "donation", feature = "testutils"))]
3631+ pub fn get_donation_receipt(env: Env, donor: Address, donation_index: u32) -> DonationReceipt {
3632+ env.storage()
3633+ .instance()
3634+ .get(&DataKey::DonationReceiptNFT(donor, donation_index))
3635+ .expect("Receipt not found")
3636+ }
3637+
35773638 #[cfg(any(feature = "usdc", feature = "donation", feature = "testutils"))]
35783639 pub fn settle_attestation(env: Env, attestation_contract: Address, attestation_id: u64) {
35793640 require_not_paused(&env);
@@ -16644,4 +16705,70 @@ mod tests {
1664416705 assert!(for_votes <= 6 + 8);
1664516706 assert_eq!(against_votes, 0);
1664616707 }
16708+
16709+ // ─── Donation receipt NFT (#945) ──────────────────────────────────────────
16710+ #[test]
16711+ fn test_mint_donation_receipt() {
16712+ let (env, _cid, client, _admin, pid) = setup();
16713+ let donor = Address::generate(&env);
16714+ let token_admin = Address::generate(&env);
16715+ let token = env
16716+ .register_stellar_asset_contract_v2(token_admin)
16717+ .address();
16718+ StellarAssetClient::new(&env, &token).mint(&donor, &(25 * STROOP));
16719+ client.donate(&token, &donor, &pid, &(25 * STROOP), &0u32);
16720+ let idx = client.mint_donation_receipt(&donor, &0u32);
16721+ assert_eq!(idx, 0u32);
16722+ assert!(client.has_donation_receipt(&donor, &0u32));
16723+ let receipt = client.get_donation_receipt(&donor, &0u32);
16724+ assert_eq!(receipt.donor, donor);
16725+ assert_eq!(receipt.project_id, pid);
16726+ assert_eq!(receipt.amount, 25 * STROOP);
16727+ assert_eq!(receipt.currency, symbol_short!("XLM"));
16728+ assert!(receipt.co2_offset > 0);
16729+ }
16730+
16731+ #[test]
16732+ fn test_mint_donation_receipt_fails_without_record() {
16733+ let (env, _cid, client, _admin, _pid) = setup();
16734+ let donor = Address::generate(&env);
16735+ // No donation has been made, so donation index 0 has no record.
16736+ let result = client.try_mint_donation_receipt(&donor, &0u32);
16737+ assert!(result.is_err());
16738+ }
16739+
16740+ #[test]
16741+ fn test_mint_donation_receipt_is_idempotent() {
16742+ let (env, _cid, client, _admin, pid) = setup();
16743+ let donor = Address::generate(&env);
16744+ let token_admin = Address::generate(&env);
16745+ let token = env
16746+ .register_stellar_asset_contract_v2(token_admin)
16747+ .address();
16748+ StellarAssetClient::new(&env, &token).mint(&donor, &(25 * STROOP));
16749+ client.donate(&token, &donor, &pid, &(25 * STROOP), &0u32);
16750+ client.mint_donation_receipt(&donor, &0u32);
16751+ let result = client.try_mint_donation_receipt(&donor, &0u32);
16752+ assert!(result.is_err());
16753+ }
16754+
16755+ #[test]
16756+ fn test_mint_donation_receipt_multiple_donations() {
16757+ let (env, _cid, client, _admin, pid) = setup();
16758+ let donor = Address::generate(&env);
16759+ let token_admin = Address::generate(&env);
16760+ let token = env
16761+ .register_stellar_asset_contract_v2(token_admin)
16762+ .address();
16763+ StellarAssetClient::new(&env, &token).mint(&donor, &(100 * STROOP));
16764+ client.donate(&token, &donor, &pid, &(25 * STROOP), &0u32);
16765+ client.donate(&token, &donor, &pid, &(25 * STROOP), &1u32);
16766+ let idx_a = client.mint_donation_receipt(&donor, &0u32);
16767+ let idx_b = client.mint_donation_receipt(&donor, &1u32);
16768+ assert_eq!(idx_a, 0u32);
16769+ assert_eq!(idx_b, 1u32);
16770+ assert!(client.has_donation_receipt(&donor, &1u32));
16771+ let receipt = client.get_donation_receipt(&donor, &1u32);
16772+ assert_eq!(receipt.amount, 25 * STROOP);
16773+ }
1664716774}
0 commit comments