Skip to content

Commit f049017

Browse files
authored
Merge pull request #450 from Akanimoh12/feat/nft-query-methods
2 parents a7504e1 + b91a567 commit f049017

9 files changed

Lines changed: 431 additions & 21 deletions

File tree

.changeset/nft-query-methods.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@iln/sdk": minor
3+
---
4+
5+
Add SDK methods to query NFT metadata and ownership: getNftMetadata() and getNftOwner() (#423). These functions expose the contract's query_nft_metadata and query_nft_owner endpoints, enabling NFT marketplace features by allowing clients to fetch complete NFT metadata including invoice details, amount, owner, and mint timestamp.

contracts/invoice_liquidity/src/lib.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod config;
1212
pub mod errors;
1313
pub mod events;
1414
pub mod invoice;
15+
pub mod nft;
1516
pub mod rate_logic;
1617
pub mod storage;
1718
pub mod top_payers;
@@ -22,6 +23,8 @@ pub mod oracle_interface;
2223
mod tests_discount_rate;
2324
#[cfg(test)]
2425
mod tests_lifecycle_integration;
26+
#[cfg(test)]
27+
mod tests_nft_query;
2528
mod tests_lp_pagination;
2629
mod tests_new_features;
2730
mod tests_pagination;
@@ -35,6 +38,7 @@ pub use crate::invoice::{
3538
AppealRecord, Invoice, InvoiceParams, InvoiceStatus, LpFundRequest, ReferralCode,
3639
ReputationProfile, ReputationScore, TopPayerEntry,
3740
};
41+
pub use crate::nft::InvoiceNftMetadata;
3842
pub use crate::storage::DataKey;
3943
pub use config::{Config, ConfigError};
4044
pub use errors::ContractError;
@@ -2279,6 +2283,47 @@ impl InvoiceLiquidityContract {
22792283
pub fn get_invoice_count(env: Env) -> u64 {
22802284
crate::invoice::read_next_invoice_id(&env) - 1
22812285
}
2286+
2287+
// ----------------------------------------------------------------
2288+
// query_nft_metadata
2289+
// ----------------------------------------------------------------
2290+
/// Get NFT metadata for an invoice
2291+
///
2292+
/// Returns complete NFT metadata including invoice ID, amount, due date,
2293+
/// discount rate, token address, current owner, and mint timestamp.
2294+
///
2295+
/// # Arguments
2296+
/// * `env` - Soroban environment
2297+
/// * `invoice_id` - The invoice ID
2298+
///
2299+
/// # Returns
2300+
/// Option containing the NFT metadata if the NFT exists, None otherwise
2301+
///
2302+
/// # Access
2303+
/// Anyone
2304+
pub fn query_nft_metadata(env: Env, invoice_id: u64) -> Option<crate::nft::InvoiceNftMetadata> {
2305+
crate::nft::query_nft_metadata(env, invoice_id)
2306+
}
2307+
2308+
// ----------------------------------------------------------------
2309+
// query_nft_owner
2310+
// ----------------------------------------------------------------
2311+
/// Get the owner of an invoice NFT
2312+
///
2313+
/// Returns the current owner address of the NFT representing the invoice.
2314+
///
2315+
/// # Arguments
2316+
/// * `env` - Soroban environment
2317+
/// * `invoice_id` - The invoice ID
2318+
///
2319+
/// # Returns
2320+
/// Option containing the owner address if the NFT exists, None otherwise
2321+
///
2322+
/// # Access
2323+
/// Anyone
2324+
pub fn query_nft_owner(env: Env, invoice_id: u64) -> Option<Address> {
2325+
crate::nft::query_nft_owner(env, invoice_id)
2326+
}
22822327
}
22832328

22842329
// ----------------------------------------------------------------

contracts/invoice_liquidity/src/nft.rs

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// Invoice NFT Module
2-
///
2+
///
33
/// Implements Stellar NFT standard for invoice representation on Soroban.
44
/// Each invoice is represented as a unique NFT that:
55
/// - Is minted when invoice is submitted
@@ -12,7 +12,6 @@
1212
/// - Due date
1313
/// - Discount rate
1414
/// - Token address
15-
1615
use soroban_sdk::{contracttype, Address, Env, Symbol};
1716

1817
use crate::errors::ContractError;
@@ -72,12 +71,8 @@ pub fn mint_invoice_nft(
7271
token: Address,
7372
) -> Result<(), ContractError> {
7473
// Check that NFT doesn't already exist
75-
if env
76-
.storage()
77-
.persistent()
78-
.has(&get_nft_key(invoice_id))
79-
{
80-
return Err(ContractError::InvoiceNftAlreadyExists);
74+
if env.storage().persistent().has(&get_nft_key(invoice_id)) {
75+
return Err(ContractError::AlreadyFunded);
8176
}
8277

8378
let metadata = InvoiceNftMetadata {
@@ -138,11 +133,11 @@ pub fn transfer_invoice_nft(
138133
.storage()
139134
.persistent()
140135
.get(&get_nft_key(invoice_id))
141-
.ok_or(ContractError::InvoiceNftNotFound)?;
136+
.ok_or(ContractError::InvoiceNotFound)?;
142137

143138
// Verify current owner
144139
if metadata.owner != from {
145-
return Err(ContractError::InvoiceNftNotOwned);
140+
return Err(ContractError::Unauthorized);
146141
}
147142

148143
// Update owner
@@ -190,17 +185,15 @@ pub fn burn_invoice_nft(env: &Env, invoice_id: u64, owner: Address) -> Result<()
190185
.storage()
191186
.persistent()
192187
.get(&get_nft_key(invoice_id))
193-
.ok_or(ContractError::InvoiceNftNotFound)?;
188+
.ok_or(ContractError::InvoiceNotFound)?;
194189

195190
// Verify current owner
196191
if metadata.owner != owner {
197-
return Err(ContractError::InvoiceNftNotOwned);
192+
return Err(ContractError::Unauthorized);
198193
}
199194

200195
// Remove NFT metadata
201-
env.storage()
202-
.persistent()
203-
.remove(&get_nft_key(invoice_id));
196+
env.storage().persistent().remove(&get_nft_key(invoice_id));
204197

205198
// Remove owner tracking
206199
env.storage()
@@ -233,9 +226,7 @@ pub fn burn_invoice_nft(env: &Env, invoice_id: u64, owner: Address) -> Result<()
233226
/// # Returns
234227
/// Option containing the metadata if it exists
235228
pub fn get_invoice_nft_metadata(env: &Env, invoice_id: u64) -> Option<InvoiceNftMetadata> {
236-
env.storage()
237-
.persistent()
238-
.get(&get_nft_key(invoice_id))
229+
env.storage().persistent().get(&get_nft_key(invoice_id))
239230
}
240231

241232
/// Get the current owner of an invoice NFT
@@ -261,9 +252,7 @@ pub fn get_invoice_nft_owner(env: &Env, invoice_id: u64) -> Option<Address> {
261252
/// # Returns
262253
/// true if the NFT exists, false otherwise
263254
pub fn invoice_nft_exists(env: &Env, invoice_id: u64) -> bool {
264-
env.storage()
265-
.persistent()
266-
.has(&get_nft_key(invoice_id))
255+
env.storage().persistent().has(&get_nft_key(invoice_id))
267256
}
268257

269258
/// Get invoice NFT metadata (publicly callable query function)

contracts/invoice_liquidity/src/storage.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub enum DataKey {
5050
LpInvoices(Address),
5151
/// Fixed-size min-heap of the top payers by reputation score (Issue #77).
5252
TopPayersHeap,
53+
/// NFT Metadata storage (Issue #423)
54+
InvoiceNft(u64),
55+
/// NFT Owner tracking (Issue #423)
56+
InvoiceNftOwner(u64),
5357
}
5458

5559
// ----------------------------------------------------------------
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#[cfg(test)]
2+
mod tests {
3+
use soroban_sdk::{testutils::Address as _, Address, Env};
4+
5+
use crate::{InvoiceLiquidityContract, InvoiceNftMetadata};
6+
7+
#[test]
8+
fn test_query_nft_metadata_not_found() {
9+
let env = Env::default();
10+
let contract = InvoiceLiquidityContract;
11+
12+
// Query NFT metadata for non-existent invoice
13+
let result = contract.query_nft_metadata(env, 999);
14+
15+
// Should return None
16+
assert_eq!(result, None);
17+
}
18+
19+
#[test]
20+
fn test_query_nft_owner_not_found() {
21+
let env = Env::default();
22+
let contract = InvoiceLiquidityContract;
23+
24+
// Query owner for non-existent invoice
25+
let result = contract.query_nft_owner(env, 999);
26+
27+
// Should return None
28+
assert_eq!(result, None);
29+
}
30+
}

sdk/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type {
3636
AllowanceParams,
3737
AllowanceResult,
3838
} from "./types.js";
39+
export type { InvoiceNftMetadata } from "./utils/xdrDecoder.js";
3940
export type {
4041
ILNEvent,
4142
ILNEventType,
@@ -44,6 +45,7 @@ export type {
4445
} from "./events/types.js";
4546

4647
export { getInvoice, listInvoicesBySubmitter, listInvoicesByLP } from "./methods/queries.js";
48+
export { getNftMetadata, getNftOwner } from "./methods/nft.js";
4749
export { submitInvoice } from "./methods/submitInvoice.js";
4850
export { transferLPPosition } from "./methods/transferLPPosition.js";
4951
export { cancelInvoice } from "./methods/cancelInvoice.js";

sdk/src/methods/nft.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { Account, SorobanRpc } from "@stellar/stellar-sdk";
3+
import { getNftMetadata, getNftOwner } from "./nft.js";
4+
import type { InvoiceNftMetadata } from "../utils/xdrDecoder.js";
5+
import { ILNError } from "../errors.js";
6+
7+
describe("NFT Query Methods", () => {
8+
let server: SorobanRpc.Server;
9+
let sourceAccount: Account;
10+
const contractAddress = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
11+
const networkPassphrase = "Test SDF Network ; September 2015";
12+
const invoiceId = 42n;
13+
14+
beforeEach(() => {
15+
// Create a mock server
16+
server = {
17+
simulateTransaction: vi.fn(),
18+
} as unknown as SorobanRpc.Server;
19+
20+
// Create a mock source account
21+
sourceAccount = new Account(
22+
"GBRPYHIL2CI3WHZDTOOQFC6EB4RBDAPPLYAT2FOSSYOWLCDTIR35IWJP",
23+
"0"
24+
);
25+
});
26+
27+
describe("getNftMetadata", () => {
28+
it("should return NFT metadata when NFT exists", async () => {
29+
const mockMetadata: InvoiceNftMetadata = {
30+
invoiceId: 42n,
31+
amount: 1000000n,
32+
dueDate: 1704067200,
33+
discountRate: 300,
34+
token: "CCJZ375JREG7DSHBG44D7ZLBFOXQFSWBM3L4AZXC3NZYBV3MYQ4GOHB",
35+
owner: "GBRPYHIL2CI3WHZDTOOQFC6EB4RBDAPPLYAT2FOSSYOWLCDTIR35IWJP",
36+
mintedAt: 1704067200,
37+
};
38+
39+
// Mock successful simulation
40+
vi.mocked(server.simulateTransaction).mockResolvedValueOnce({
41+
result: {
42+
retval: {
43+
type: "obj",
44+
fields: [
45+
{ key: { type: "sym", sym: "invoice_id" }, val: { type: "u64", u64: "42" } },
46+
{ key: { type: "sym", sym: "amount" }, val: { type: "i128", i128: "1000000" } },
47+
{ key: { type: "sym", sym: "due_date" }, val: { type: "u32", u32: "1704067200" } },
48+
{ key: { type: "sym", sym: "discount_rate" }, val: { type: "u32", u32: "300" } },
49+
{ key: { type: "sym", sym: "token" }, val: { type: "addr", addr: "CCJZ375JREG7DSHBG44D7ZLBFOXQFSWBM3L4AZXC3NZYBV3MYQ4GOHB" } },
50+
{ key: { type: "sym", sym: "owner" }, val: { type: "addr", addr: "GBRPYHIL2CI3WHZDTOOQFC6EB4RBDAPPLYAT2FOSSYOWLCDTIR35IWJP" } },
51+
{ key: { type: "sym", sym: "minted_at" }, val: { type: "u32", u32: "1704067200" } },
52+
],
53+
},
54+
},
55+
} as any);
56+
57+
const result = await getNftMetadata(
58+
server,
59+
contractAddress,
60+
invoiceId,
61+
sourceAccount,
62+
networkPassphrase
63+
);
64+
65+
expect(result).not.toBeNull();
66+
expect(result?.invoiceId).toBe(42n);
67+
expect(result?.amount).toBe(1000000n);
68+
expect(result?.discountRate).toBe(300);
69+
expect(result?.owner).toBe("GBRPYHIL2CI3WHZDTOOQFC6EB4RBDAPPLYAT2FOSSYOWLCDTIR35IWJP");
70+
});
71+
72+
it("should return null when NFT does not exist", async () => {
73+
// Mock simulation returning null for Option::None
74+
vi.mocked(server.simulateTransaction).mockResolvedValueOnce({
75+
result: {
76+
retval: null,
77+
},
78+
} as any);
79+
80+
const result = await getNftMetadata(
81+
server,
82+
contractAddress,
83+
invoiceId,
84+
sourceAccount,
85+
networkPassphrase
86+
);
87+
88+
expect(result).toBeNull();
89+
});
90+
91+
it("should throw ILNError on simulation error", async () => {
92+
// Mock simulation error
93+
vi.mocked(server.simulateTransaction).mockResolvedValueOnce({
94+
error: new Error("Simulation failed"),
95+
} as any);
96+
97+
await expect(
98+
getNftMetadata(server, contractAddress, invoiceId, sourceAccount, networkPassphrase)
99+
).rejects.toThrow(ILNError);
100+
});
101+
});
102+
103+
describe("getNftOwner", () => {
104+
it("should return owner address when NFT exists", async () => {
105+
const ownerAddress = "GBRPYHIL2CI3WHZDTOOQFC6EB4RBDAPPLYAT2FOSSYOWLCDTIR35IWJP";
106+
107+
// Mock successful simulation
108+
vi.mocked(server.simulateTransaction).mockResolvedValueOnce({
109+
result: {
110+
retval: {
111+
type: "addr",
112+
addr: ownerAddress,
113+
},
114+
},
115+
} as any);
116+
117+
const result = await getNftOwner(
118+
server,
119+
contractAddress,
120+
invoiceId,
121+
sourceAccount,
122+
networkPassphrase
123+
);
124+
125+
expect(result).toBe(ownerAddress);
126+
});
127+
128+
it("should return null when NFT does not exist", async () => {
129+
// Mock simulation returning null for Option::None
130+
vi.mocked(server.simulateTransaction).mockResolvedValueOnce({
131+
result: {
132+
retval: null,
133+
},
134+
} as any);
135+
136+
const result = await getNftOwner(
137+
server,
138+
contractAddress,
139+
invoiceId,
140+
sourceAccount,
141+
networkPassphrase
142+
);
143+
144+
expect(result).toBeNull();
145+
});
146+
147+
it("should throw ILNError on simulation error", async () => {
148+
// Mock simulation error
149+
vi.mocked(server.simulateTransaction).mockResolvedValueOnce({
150+
error: new Error("Simulation failed"),
151+
} as any);
152+
153+
await expect(
154+
getNftOwner(server, contractAddress, invoiceId, sourceAccount, networkPassphrase)
155+
).rejects.toThrow(ILNError);
156+
});
157+
});
158+
});

0 commit comments

Comments
 (0)