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}