Skip to content

Commit 7634800

Browse files
committed
Point new mint uris at the gateway skill route; readers extract the sig
New skill/workflow mints write uri = {gateway}/skill/{mint}/{sig} so external viewers (marketplaces, explorers, wallets) can resolve standard NFT JSON and a card image over HTTP. The code-in txid stays recoverable on-chain: it is the uri's last path segment, and every reader now funnels through inscriptionSigOf() to extract it before readCodeIn — the gateway attempt and the RPC fallback both receive the bare sig, never the URL. Legacy bare-sig uris keep working through the same parse (the two existing mainnet mints migrate separately).
1 parent b2d2049 commit 7634800

8 files changed

Lines changed: 120 additions & 24 deletions

File tree

packages/core/src/core/chain.spec.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { Connection, PublicKey, Keypair } from "@solana/web3.js";
3-
import { init, ensureDbRoot, createTable, writeRow, codeIn, signerAddress, tableExists, readCodeIn, readRows } from "./chain.js";
3+
import { init, ensureDbRoot, createTable, writeRow, codeIn, signerAddress, tableExists, readCodeIn, readRows, inscriptionSigOf, itemMetadataUri } from "./chain.js";
44
import { readCodeIn as sdkReadCodeIn, readTableRows as sdkReadTableRows } from "@iqlabs-official/solana-sdk/reader";
55

66
// Mock the solana-sdk modules
@@ -161,4 +161,35 @@ describe("core/chain", () => {
161161
vi.unstubAllGlobals();
162162
});
163163
});
164+
165+
describe("inscriptionSigOf / itemMetadataUri", () => {
166+
const SIG = "4K3z7cWH8QAxd74tK4ErtNv8ZL1k97yrAsm7SQJg4zPRya7DnbsxtmZwEgza4H34Z6QX9ewjqpmospXe3KQahruh";
167+
const MINT = "Es18ADJ4ZpKDojLtvNJEBGCaXpqxsmw2kG8ihS6TJC7U";
168+
169+
it("passes a legacy bare signature through", () => {
170+
expect(inscriptionSigOf(SIG)).toBe(SIG);
171+
});
172+
173+
it("extracts the sig from a gateway skill URL (last segment)", () => {
174+
expect(inscriptionSigOf(`https://gateway.iqlabs.dev/skill/${MINT}/${SIG}`)).toBe(SIG);
175+
});
176+
177+
it("tolerates .png suffix, trailing slash and query", () => {
178+
expect(inscriptionSigOf(`https://gateway.iqlabs.dev/skill/${MINT}/${SIG}.png`)).toBe(SIG);
179+
expect(inscriptionSigOf(`https://gateway.iqlabs.dev/skill/${MINT}/${SIG}/`)).toBe(SIG);
180+
expect(inscriptionSigOf(`https://gateway.iqlabs.dev/skill/${MINT}/${SIG}?v=1`)).toBe(SIG);
181+
});
182+
183+
it("rejects things that carry no signature", () => {
184+
expect(inscriptionSigOf("txid123")).toBeNull(); // too short
185+
expect(inscriptionSigOf(`https://gateway.iqlabs.dev/skill/${MINT}`)).toBeNull(); // mint tail, not a sig
186+
expect(inscriptionSigOf("")).toBeNull();
187+
});
188+
189+
it("itemMetadataUri builds a URL whose tail round-trips through inscriptionSigOf", () => {
190+
const uri = itemMetadataUri(MINT, SIG);
191+
expect(uri.endsWith(`/skill/${MINT}/${SIG}`)).toBe(true);
192+
expect(inscriptionSigOf(uri)).toBe(SIG);
193+
});
194+
});
164195
});

packages/core/src/core/chain.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,31 @@ export async function codeIn(
323323
);
324324
}
325325

326+
// A mint uri carries the inscription signature in one of two shapes: legacy
327+
// uris are the bare tx signature; current uris are the gateway presentation
328+
// URL "{gateway}/skill/{mint}/{sig}" (marketplace-readable JSON+image) whose
329+
// LAST path segment is the signature (the same tail convention iq-wide-web
330+
// uses for SOL records). Every on-chain read extracts the sig and resolves
331+
// content through readCodeIn, so the app never depends on the HTTP route.
332+
const SIG_SHAPE = /^[1-9A-HJ-NP-Za-km-z]{80,90}$/;
333+
334+
export function inscriptionSigOf(uri: string): string | null {
335+
if (SIG_SHAPE.test(uri)) return uri;
336+
if (/^https?:\/\//.test(uri)) {
337+
const tail = uri.split(/[?#]/, 1)[0].replace(/\/+$/, "").split("/").pop() ?? "";
338+
const sig = tail.replace(/\.(png|json)$/, "");
339+
if (SIG_SHAPE.test(sig)) return sig;
340+
}
341+
return null;
342+
}
343+
344+
/** The uri written onto a new item mint: the gateway route that serves
345+
* standard NFT JSON (+ card image) for external viewers. Network-matched via
346+
* gatewayUrl(); both identifiers are known before the mint tx is signed. */
347+
export function itemMetadataUri(mint: string, sig: string): string {
348+
return `${gatewayUrl()}/skill/${mint}/${sig}`;
349+
}
350+
326351
// Read a code-in inscription by its tx signature. Tries the gateway's cached
327352
// `/data/{sig}` first (network-matched via getGatewayUrl — the mainnet gateway
328353
// can't resolve a devnet tx, so the URL must follow the network); on any failure

packages/core/src/nft/readSkillText.spec.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import * as chain from "../core/chain.js";
44
import * as splToken from "@solana/spl-token";
55

66
// readSkillText joins readSkillMintMetadata (getTokenMetadata) → readCodeIn.
7-
vi.mock("../core/chain.js", () => ({
8-
signerAddress: vi.fn(),
9-
readCodeIn: vi.fn(),
10-
}));
7+
// Keep the real inscriptionSigOf: the uri→sig extraction IS part of the path
8+
// under test (legacy bare-sig uris and gateway-URL uris must both resolve).
9+
vi.mock("../core/chain.js", async (importOriginal) => {
10+
const actual = await importOriginal<typeof import("../core/chain.js")>();
11+
return { ...actual, signerAddress: vi.fn(), readCodeIn: vi.fn() };
12+
});
1113

1214
vi.mock("./minter.js", () => ({
1315
resolveMinter: vi.fn(),
@@ -20,33 +22,63 @@ vi.mock("@solana/spl-token", async (importOriginal) => {
2022
});
2123

2224
const MINT = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPvZeJ";
25+
// A realistically-shaped inscription signature (64 raw bytes → 87-88 base58).
26+
const TXID = "4K3z7cWH8QAxd74tK4ErtNv8ZL1k97yrAsm7SQJg4zPRya7DnbsxtmZwEgza4H34Z6QX9ewjqpmospXe3KQahruh";
27+
28+
const SKILL_JSON = JSON.stringify({
29+
name: "my-skill",
30+
description: "d",
31+
attributes: [{ trait_type: "category", value: "ai" }],
32+
skillText: "# SKILL body text",
33+
});
2334

2435
describe("nft/readSkillText", () => {
2536
beforeEach(() => vi.clearAllMocks());
2637

27-
it("resolves mint → uri (txid) → skillText in the code-in JSON", async () => {
38+
it("resolves mint → legacy bare-sig uri → skillText in the code-in JSON", async () => {
2839
vi.mocked(splToken.getTokenMetadata).mockResolvedValue({
2940
name: "my-skill",
3041
symbol: "MY",
31-
uri: "txid123",
42+
uri: TXID,
3243
additionalMetadata: [],
3344
} as any);
3445
// The inscription is the standard NFT JSON; the body is its skillText field.
35-
vi.mocked(chain.readCodeIn).mockResolvedValue({
36-
data: JSON.stringify({
37-
name: "my-skill",
38-
description: "d",
39-
attributes: [{ trait_type: "category", value: "ai" }],
40-
skillText: "# SKILL body text",
41-
}),
42-
metadata: "",
43-
});
46+
vi.mocked(chain.readCodeIn).mockResolvedValue({ data: SKILL_JSON, metadata: "" });
4447

4548
const text = await readSkillText({} as any, MINT);
4649

4750
expect(text).toBe("# SKILL body text");
48-
// the uri recovered from the mint is what readCodeIn is asked for
49-
expect(chain.readCodeIn).toHaveBeenCalledWith("txid123");
51+
// the sig recovered from the mint uri is what readCodeIn is asked for
52+
expect(chain.readCodeIn).toHaveBeenCalledWith(TXID);
53+
});
54+
55+
it("resolves a gateway-URL uri by extracting the sig from its last segment", async () => {
56+
vi.mocked(splToken.getTokenMetadata).mockResolvedValue({
57+
name: "my-skill",
58+
symbol: "MY",
59+
uri: `https://gateway.iqlabs.dev/skill/${MINT}/${TXID}`,
60+
additionalMetadata: [],
61+
} as any);
62+
vi.mocked(chain.readCodeIn).mockResolvedValue({ data: SKILL_JSON, metadata: "" });
63+
64+
const text = await readSkillText({} as any, MINT);
65+
66+
expect(text).toBe("# SKILL body text");
67+
expect(chain.readCodeIn).toHaveBeenCalledWith(TXID);
68+
});
69+
70+
it("returns null (no on-chain read) when the uri carries no recognisable sig", async () => {
71+
vi.mocked(splToken.getTokenMetadata).mockResolvedValue({
72+
name: "my-skill",
73+
symbol: "MY",
74+
uri: "txid123",
75+
additionalMetadata: [],
76+
} as any);
77+
78+
const text = await readSkillText({} as any, MINT);
79+
80+
expect(text).toBeNull();
81+
expect(chain.readCodeIn).not.toHaveBeenCalled();
5082
});
5183

5284
it("returns null when the mint has no metadata", async () => {

packages/core/src/nft/skill.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock("../core/chain.js", () => ({
1111
signerAddress: vi.fn().mockImplementation((signer) =>
1212
Promise.resolve(signer.publicKey?.toBase58() || "11111111111111111111111111111111"),
1313
),
14+
itemMetadataUri: vi.fn((mint: string, sig: string) => `https://gateway.test/skill/${mint}/${sig}`),
1415
}));
1516

1617
vi.mock("../core/seed.js", () => ({

packages/core/src/nft/skill.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
DIRECT_METADATA_MAX_BYTES,
2323
} from "@iqlabs-official/solana-sdk/constants";
2424
import { getUserInventoryPda, PROGRAM_ID as CODE_IN_PROGRAM_ID } from "@iqlabs-official/solana-sdk/contract";
25-
import { codeIn, signerAddress, ensureDbRoot, dbRootExists } from "../core/chain.js";
25+
import { codeIn, signerAddress, ensureDbRoot, dbRootExists, itemMetadataUri } from "../core/chain.js";
2626
import { getSkillsCollectionMint } from "../core/seed.js";
2727
import { createSkillMint } from "./token2022.js";
2828
import { checkFormat, FormatError } from "./checkFormat.js";
@@ -245,7 +245,9 @@ export async function publishSkill(
245245
await createSkillMint(conn, tx, {
246246
name: input.name,
247247
symbol: input.name.substring(0, 8).toUpperCase(),
248-
uri: skillTxid, // points at the JSON above (traits live there, not on the mint)
248+
// Gateway presentation URL (marketplaces resolve it for JSON+image); its
249+
// last segment is the code-in txid, which on-chain readers extract.
250+
uri: itemMetadataUri(skillMint.toBase58(), skillTxid),
249251
collectionMint,
250252
mintKeypair: skillMintKp,
251253
minterAuthority: mintAuthority, // gate PDA holds the mint authority

packages/core/src/nft/token2022.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
type TokenMetadata,
3232
} from "@solana/spl-token-metadata";
3333
import { type SignerInput, type WalletSigner } from "@iqlabs-official/solana-sdk/utils";
34-
import { signerAddress, readCodeIn } from "../core/chain.js";
34+
import { signerAddress, readCodeIn, inscriptionSigOf } from "../core/chain.js";
3535
import { resolveMinter, tryMinterPubkey } from "./minter.js";
3636

3737
import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
@@ -290,9 +290,11 @@ export async function readSkillMintMetadata(
290290

291291
const base: SkillMintMetadata = { name: md.name, symbol: md.symbol, uri: md.uri };
292292
if (!md.uri) return base;
293+
const sig = inscriptionSigOf(md.uri);
294+
if (!sig) return base; // uri carries no inscription signature we recognise
293295

294296
try {
295-
const { data } = await readCodeIn(md.uri);
297+
const { data } = await readCodeIn(sig);
296298
if (!data) return base;
297299
const json = JSON.parse(data) as SkillJson;
298300
const { category, hashtags } = traitsFromAttributes(json.attributes);

packages/core/src/nft/workflow.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vi.mock("../core/chain.js", () => ({
1111
signerAddress: vi.fn().mockImplementation((signer) =>
1212
Promise.resolve(signer.publicKey?.toBase58() || "11111111111111111111111111111111"),
1313
),
14+
itemMetadataUri: vi.fn((mint: string, sig: string) => `https://gateway.test/skill/${mint}/${sig}`),
1415
}));
1516

1617
vi.mock("../core/seed.js", () => ({

packages/core/src/nft/workflow.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from "@solana/spl-token";
1313
import type { SignerInput } from "@iqlabs-official/solana-sdk/utils";
1414

15-
import { codeIn, signerAddress, ensureDbRoot } from "../core/chain.js";
15+
import { codeIn, signerAddress, ensureDbRoot, itemMetadataUri } from "../core/chain.js";
1616
import { getWorkflowsCollectionMint } from "../core/seed.js";
1717
import { trackSignatures, estimatePublishSigns, type PublishProgress } from "./skill.js";
1818
import { createSkillMint } from "./token2022.js";
@@ -105,7 +105,9 @@ export async function publishWorkflow(
105105
await createSkillMint(conn, tx, {
106106
name: input.name,
107107
symbol: input.name.substring(0, 8).toUpperCase(),
108-
uri: workflowTxid, // points at the JSON above (traits live there, not on the mint)
108+
// Gateway presentation URL (marketplaces resolve it for JSON+image); its
109+
// last segment is the code-in txid, which on-chain readers extract.
110+
uri: itemMetadataUri(workflowMint.toBase58(), workflowTxid),
109111
collectionMint,
110112
mintKeypair: workflowMintKp,
111113
minterAuthority: mintAuthority, // gate PDA holds the mint authority

0 commit comments

Comments
 (0)