Skip to main content

chip35_dl_coin/
gating.rs

1//! NFT-gating: PROVE NFT / collection ownership so a dapp can gate access on it (roadmap #46).
2//!
3//! Read/verify only — there is NO spend here. Given the coin spend that created an NFT's CURRENT coin
4//! (fetched from a full node by the caller) and a claimed owner, this reconstructs the NFT and checks:
5//! who owns it (the inner p2 puzzle hash = the owner's address), which collection/creator it is
6//! attributed to (the singleton's `current_owner`, i.e. the creator DID), and its launcher id. A dapp
7//! gates by asserting `owner == the connected wallet` AND (optionally) `attributed_did == the
8//! required collection DID` / `launcher_id == a specific required NFT`.
9//!
10//! Why a coin spend, not a coin: an NFT's CURRENT owner + attribution live in the inner puzzle, which
11//! is only revealed when the coin is spent. The caller fetches the spend that produced the latest
12//! (unspent) NFT coin — `parse_child` reconstructs that latest coin's [`NftInfo`] from its parent
13//! spend. Confirming the resulting coin is unspent (i.e. the NFT is still held) is the caller's job
14//! (a coinset `get_coin_record_by_name` on [`NftOwnershipProof::nft_coin_id`]); this module proves
15//! the on-chain FACTS, the caller pairs them with liveness.
16
17use chia_protocol::{Bytes32, CoinSpend};
18use chia_sdk_driver::{Nft, Puzzle, SpendContext};
19
20/// The facts a successful proof establishes about an NFT (all read from the chain, none asserted).
21///
22/// A dapp turns these into a gate: require `owner_puzzle_hash == connected wallet`, and optionally
23/// `attributed_did == required collection/creator DID` and/or `launcher_id == a specific NFT`.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct NftOwnershipProof {
26    /// The NFT's launcher id (its permanent identifier).
27    pub launcher_id: Bytes32,
28    /// The current owner's puzzle hash (the NFT's inner p2 puzzle hash = the holder's address).
29    pub owner_puzzle_hash: Bytes32,
30    /// The singleton this NFT is attributed to (the creator/collection DID launcher id), if any. An
31    /// unassigned NFT (e.g. mid-transfer or in an offer) has `None`.
32    pub attributed_did: Option<Bytes32>,
33    /// The current (unspent) NFT coin id — the caller checks it is still unspent for liveness.
34    pub nft_coin_id: Bytes32,
35}
36
37/// The reasons an NFT gate can fail — actionable so a dapp can tell the user why access was denied.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum GatingError {
40    /// The supplied coin spend is not an NFT spend (cannot reconstruct an NFT from it).
41    NotAnNft,
42    /// The NFT is held by a different puzzle hash than the claimed owner.
43    WrongOwner { expected: Bytes32, got: Bytes32 },
44    /// The NFT is not attributed to the required collection/creator DID.
45    WrongCollection {
46        required: Bytes32,
47        got: Option<Bytes32>,
48    },
49    /// The NFT's launcher id is not the required one.
50    WrongNft { required: Bytes32, got: Bytes32 },
51}
52
53impl GatingError {
54    /// A stable, machine-readable `UPPER_SNAKE` code a dapp/agent can branch on instead of
55    /// string-matching the human [`Display`] message. Part of the public contract (changing one is a
56    /// breaking change); surfaced as the `code` field of the `{ ok:false, code, error }` result the
57    /// wasm gating helpers return.
58    pub fn code(&self) -> &'static str {
59        match self {
60            GatingError::NotAnNft => "NOT_AN_NFT",
61            GatingError::WrongOwner { .. } => "WRONG_OWNER",
62            GatingError::WrongCollection { .. } => "WRONG_COLLECTION",
63            GatingError::WrongNft { .. } => "WRONG_NFT",
64        }
65    }
66}
67
68impl core::fmt::Display for GatingError {
69    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
70        match self {
71            GatingError::NotAnNft => write!(f, "coin spend is not an NFT spend"),
72            GatingError::WrongOwner { expected, got } => {
73                write!(f, "NFT owner {got} is not the claimed owner {expected}")
74            }
75            GatingError::WrongCollection { required, got } => {
76                write!(f, "NFT collection {got:?} is not the required {required}")
77            }
78            GatingError::WrongNft { required, got } => {
79                write!(f, "NFT {got} is not the required {required}")
80            }
81        }
82    }
83}
84
85/// Reconstruct the CURRENT [`NftOwnershipProof`] for an NFT from the coin spend that created its
86/// latest coin.
87///
88/// `parent_spend` is the spend of the NFT's PARENT coin (the spend that produced the current unspent
89/// NFT coin) — the caller fetches it from a full node. Returns the read facts (owner, attributed DID,
90/// launcher id, current coin id) or [`GatingError::NotAnNft`] if the spend isn't an NFT.
91///
92/// This does no gating itself; use [`prove_nft_ownership`] / [`prove_collection_membership`] to apply
93/// a require check, or read the facts and decide in the dapp.
94pub fn read_nft_ownership(parent_spend: &CoinSpend) -> Result<NftOwnershipProof, GatingError> {
95    let mut ctx = SpendContext::new();
96    let puzzle_ptr = ctx
97        .alloc(&parent_spend.puzzle_reveal)
98        .map_err(|_| GatingError::NotAnNft)?;
99    let solution_ptr = ctx
100        .alloc(&parent_spend.solution)
101        .map_err(|_| GatingError::NotAnNft)?;
102    let puzzle = Puzzle::parse(&ctx, puzzle_ptr);
103
104    let nft = Nft::parse_child(&mut ctx, parent_spend.coin, puzzle, solution_ptr)
105        .map_err(|_| GatingError::NotAnNft)?
106        .ok_or(GatingError::NotAnNft)?;
107
108    Ok(NftOwnershipProof {
109        launcher_id: nft.info.launcher_id,
110        owner_puzzle_hash: nft.info.p2_puzzle_hash,
111        attributed_did: nft.info.current_owner,
112        nft_coin_id: nft.coin.coin_id(),
113    })
114}
115
116/// Prove an NFT is owned by `claimed_owner_puzzle_hash` (roadmap #46 NFT-gating).
117///
118/// Reconstructs the NFT from `parent_spend` (see [`read_nft_ownership`]) and asserts its current
119/// holder is `claimed_owner_puzzle_hash`. When `required_nft` is set, also asserts the NFT's launcher
120/// id matches (gate on a SPECIFIC NFT). Returns the proof facts on success.
121///
122/// The caller still confirms [`NftOwnershipProof::nft_coin_id`] is unspent on-chain (liveness) before
123/// granting access.
124pub fn prove_nft_ownership(
125    parent_spend: &CoinSpend,
126    claimed_owner_puzzle_hash: Bytes32,
127    required_nft: Option<Bytes32>,
128) -> Result<NftOwnershipProof, GatingError> {
129    let proof = read_nft_ownership(parent_spend)?;
130    if proof.owner_puzzle_hash != claimed_owner_puzzle_hash {
131        return Err(GatingError::WrongOwner {
132            expected: claimed_owner_puzzle_hash,
133            got: proof.owner_puzzle_hash,
134        });
135    }
136    if let Some(required) = required_nft {
137        if proof.launcher_id != required {
138            return Err(GatingError::WrongNft {
139                required,
140                got: proof.launcher_id,
141            });
142        }
143    }
144    Ok(proof)
145}
146
147/// Prove an NFT held by `claimed_owner_puzzle_hash` is a member of the collection/creator identified
148/// by `required_did` (roadmap #46 collection-gating).
149///
150/// Combines the owner check with an attribution check: the NFT's `current_owner` (its creator/
151/// collection DID) must equal `required_did`. This is the "gate on collection membership" path — a
152/// dapp requires holders of any NFT minted under a given creator DID. Returns the proof facts.
153///
154/// NOTE: this proves attribution to the creator DID the NFT carries on-chain (`current_owner`), which
155/// is how DIG mints attribute a collection (see [`crate::collection`] / [`crate::nft::DidAttribution`]).
156/// Verifying the DID itself authorized that attribution (vs. a spoofed launcher id) is a deeper check
157/// the caller layers on by also proving the DID — out of scope for this read helper.
158pub fn prove_collection_membership(
159    parent_spend: &CoinSpend,
160    claimed_owner_puzzle_hash: Bytes32,
161    required_did: Bytes32,
162) -> Result<NftOwnershipProof, GatingError> {
163    let proof = prove_nft_ownership(parent_spend, claimed_owner_puzzle_hash, None)?;
164    if proof.attributed_did != Some(required_did) {
165        return Err(GatingError::WrongCollection {
166            required: required_did,
167            got: proof.attributed_did,
168        });
169    }
170    Ok(proof)
171}