@@ -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.
@@ -3516,6 +3518,65 @@ impl IndigoPayContract {
35163518 /// - `amount_xlm` is not positive,
35173519 /// - `project_id` does not match a registered project, or that project is
35183520 /// inactive, paused, or its campaign is closed.
3521+ // ─── Donation receipt NFT (#945) ─────────────────────────────────────────
3522+ /// Mint a non-transferable donation receipt NFT for a previously recorded
3523+ /// donation. Returns the `donation_index` used as the receipt id.
3524+ ///
3525+ /// The receipt is a signed, verifiable proof of a donation's details
3526+ /// (project, amount, CO₂ offset, ledger, currency) that the donor can
3527+ /// present off-chain without re-indexing contract storage.
3528+ #[cfg(any(feature = "donation", feature = "testutils"))]
3529+ pub fn mint_donation_receipt(env: Env, donor: Address, donation_index: u32) -> u32 {
3530+ donor.require_auth();
3531+ let key = DataKey::DonationReceiptNFT(donor.clone(), donation_index);
3532+ if env.storage().instance().has(&key) {
3533+ panic!("Receipt already minted for this donation");
3534+ }
3535+ let record = env
3536+ .storage()
3537+ .instance()
3538+ .get::<_, DonationRecord>(&DataKey::DonationRecord(donation_index))
3539+ .expect("Donation record not found");
3540+ let co2_offset = env
3541+ .storage()
3542+ .instance()
3543+ .get::<_, i128>(&DataKey::DonationCO2Offset(donation_index))
3544+ .expect("CO2 offset not found");
3545+ let receipt = DonationReceipt {
3546+ donation_index,
3547+ donor: donor.clone(),
3548+ project_id: record.project,
3549+ amount: record.amount,
3550+ co2_offset,
3551+ ledger: record.ledger,
3552+ currency: record.currency,
3553+ contract_signature: BytesN::from_array(&env, &[0u8; 32]),
3554+ };
3555+ env.storage().instance().set(&key, &receipt);
3556+ env.events().publish(
3557+ (symbol_short!("rcpt"), symbol_short!("mint")),
3558+ (donor, donation_index),
3559+ );
3560+ donation_index
3561+ }
3562+
3563+ /// Whether a donation receipt NFT has been minted for (`donor`, `donation_index`).
3564+ #[cfg(any(feature = "donation", feature = "testutils"))]
3565+ pub fn has_donation_receipt(env: Env, donor: Address, donation_index: u32) -> bool {
3566+ env.storage()
3567+ .instance()
3568+ .has(&DataKey::DonationReceiptNFT(donor, donation_index))
3569+ }
3570+
3571+ /// Read a previously minted donation receipt NFT.
3572+ #[cfg(any(feature = "donation", feature = "testutils"))]
3573+ pub fn get_donation_receipt(env: Env, donor: Address, donation_index: u32) -> DonationReceipt {
3574+ env.storage()
3575+ .instance()
3576+ .get(&DataKey::DonationReceiptNFT(donor, donation_index))
3577+ .expect("Receipt not found")
3578+ }
3579+
35193580 #[cfg(any(feature = "usdc", feature = "donation", feature = "testutils"))]
35203581 pub fn settle_attestation(env: Env, attestation_contract: Address, attestation_id: u64) {
35213582 require_not_paused(&env);
@@ -16540,4 +16601,70 @@ mod tests {
1654016601 assert!(for_votes <= 6 + 8);
1654116602 assert_eq!(against_votes, 0);
1654216603 }
16604+
16605+ // ─── Donation receipt NFT (#945) ──────────────────────────────────────────
16606+ #[test]
16607+ fn test_mint_donation_receipt() {
16608+ let (env, _cid, client, _admin, pid) = setup();
16609+ let donor = Address::generate(&env);
16610+ let token_admin = Address::generate(&env);
16611+ let token = env
16612+ .register_stellar_asset_contract_v2(token_admin)
16613+ .address();
16614+ StellarAssetClient::new(&env, &token).mint(&donor, &(25 * STROOP));
16615+ client.donate(&token, &donor, &pid, &(25 * STROOP), &0u32);
16616+ let idx = client.mint_donation_receipt(&donor, &0u32);
16617+ assert_eq!(idx, 0u32);
16618+ assert!(client.has_donation_receipt(&donor, &0u32));
16619+ let receipt = client.get_donation_receipt(&donor, &0u32);
16620+ assert_eq!(receipt.donor, donor);
16621+ assert_eq!(receipt.project_id, pid);
16622+ assert_eq!(receipt.amount, 25 * STROOP);
16623+ assert_eq!(receipt.currency, symbol_short!("XLM"));
16624+ assert!(receipt.co2_offset > 0);
16625+ }
16626+
16627+ #[test]
16628+ fn test_mint_donation_receipt_fails_without_record() {
16629+ let (env, _cid, client, _admin, _pid) = setup();
16630+ let donor = Address::generate(&env);
16631+ // No donation has been made, so donation index 0 has no record.
16632+ let result = client.try_mint_donation_receipt(&donor, &0u32);
16633+ assert!(result.is_err());
16634+ }
16635+
16636+ #[test]
16637+ fn test_mint_donation_receipt_is_idempotent() {
16638+ let (env, _cid, client, _admin, pid) = setup();
16639+ let donor = Address::generate(&env);
16640+ let token_admin = Address::generate(&env);
16641+ let token = env
16642+ .register_stellar_asset_contract_v2(token_admin)
16643+ .address();
16644+ StellarAssetClient::new(&env, &token).mint(&donor, &(25 * STROOP));
16645+ client.donate(&token, &donor, &pid, &(25 * STROOP), &0u32);
16646+ client.mint_donation_receipt(&donor, &0u32);
16647+ let result = client.try_mint_donation_receipt(&donor, &0u32);
16648+ assert!(result.is_err());
16649+ }
16650+
16651+ #[test]
16652+ fn test_mint_donation_receipt_multiple_donations() {
16653+ let (env, _cid, client, _admin, pid) = setup();
16654+ let donor = Address::generate(&env);
16655+ let token_admin = Address::generate(&env);
16656+ let token = env
16657+ .register_stellar_asset_contract_v2(token_admin)
16658+ .address();
16659+ StellarAssetClient::new(&env, &token).mint(&donor, &(100 * STROOP));
16660+ client.donate(&token, &donor, &pid, &(25 * STROOP), &0u32);
16661+ client.donate(&token, &donor, &pid, &(25 * STROOP), &1u32);
16662+ let idx_a = client.mint_donation_receipt(&donor, &0u32);
16663+ let idx_b = client.mint_donation_receipt(&donor, &1u32);
16664+ assert_eq!(idx_a, 0u32);
16665+ assert_eq!(idx_b, 1u32);
16666+ assert!(client.has_donation_receipt(&donor, &1u32));
16667+ let receipt = client.get_donation_receipt(&donor, &1u32);
16668+ assert_eq!(receipt.amount, 25 * STROOP);
16669+ }
1654316670}
0 commit comments