diff --git a/packages/app/src/components/connect/AnchorRequestPanel.tsx b/packages/app/src/components/connect/AnchorRequestPanel.tsx new file mode 100644 index 0000000..5f4c495 --- /dev/null +++ b/packages/app/src/components/connect/AnchorRequestPanel.tsx @@ -0,0 +1,155 @@ +/** + * Approval screen for an incoming `anchor-request`: publishing a signed Canon + * declaration on-chain as a permanent `cnd1` commit+reveal pair, paid from + * this wallet. Shows the parsed declaration (what the signing key recognizes + * or revokes), the signer, and a permanence warning before `Connect.tsx` + * calls `anchorFromRequest`. The exact document is available to inspect — + * its bytes are what gets committed. + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Badge, + Box, + Button, + Code, + HStack, + Stack, + Text, +} from "@chakra-ui/react"; +import { MdAnchor, MdWarning } from "react-icons/md"; +import Card from "@app/components/Card"; +import { canonDeclarationFromDocument } from "@app/connect/protocol"; +import type { AnchorRequest } from "@app/connect/protocol"; +import { sanitizeForDisplay } from "@lib/displayText"; + +export default function AnchorRequestPanel({ + request, + busy, + onApprove, + onReject, +}: { + request: AnchorRequest; + busy: boolean; + onApprove: () => void; + onReject: () => void; +}) { + // Guaranteed parseable — normalizeAnchorEnvelope refused anything else. + const parsed = canonDeclarationFromDocument(request.document); + if (!parsed) return null; + const { declaration } = parsed; + + return ( + + + + Anchor a Canon declaration + + Canon declaration + + + + {request.origin || request.app ? ( + + + Requested by + + + {request.app ? `${sanitizeForDisplay(request.app)} — ` : ""} + {request.origin ? sanitizeForDisplay(request.origin) : "(no origin provided)"} + + + ) : null} + + + + The declaration being published + + + {declaration.declares.map((entry) => ( + + + Key recognizes the{" "} + + {entry.kind === "container" + ? "collection" + : entry.kind === "work" + ? "individual work" + : "creator token"} + + {entry.label ? ` “${sanitizeForDisplay(entry.label)}”` : ""} + + + {entry.ref} + + + ))} + {declaration.revokes.map((ref) => ( + + + Key withdraws recognition of + + + {ref} + + + ))} + + Signer {sanitizeForDisplay(declaration.signer)} · dated{" "} + {sanitizeForDisplay(declaration.issued)} ·{" "} + {declaration.expires + ? `expires ${sanitizeForDisplay(declaration.expires)}` + : "no expiry"} + + {declaration.comment ? ( + + “{sanitizeForDisplay(declaration.comment)}” + + ) : null} + + + + + + + Anchoring is permanent + + This publishes the declaration to the blockchain forever — it cannot be deleted, + only superseded by a later one. This wallet pays the transaction fees. + + + + + + + Exact document to be committed ({request.document.length} bytes) + + + {sanitizeForDisplay(request.document)} + + + + + + + + + + ); +} diff --git a/packages/app/src/components/connect/AnchorResultPanel.tsx b/packages/app/src/components/connect/AnchorResultPanel.tsx new file mode 100644 index 0000000..bec6964 --- /dev/null +++ b/packages/app/src/components/connect/AnchorResultPanel.tsx @@ -0,0 +1,107 @@ +/** + * Result screen after an `anchor-request` completes: the commit + reveal + * txids (or, for a dry run — `broadcast: false` — the raw unsent hex) and + * the document's docHash identity. Mirrors `MintResultPanel`'s shape. + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Code, + Divider, + Stack, + Text, + useClipboard, +} from "@chakra-ui/react"; +import { MdCheck, MdContentCopy } from "react-icons/md"; +import Card from "@app/components/Card"; +import type { AnchorResult } from "@app/connect/protocol"; + +function CopyField({ label, value }: { label: string; value: string }) { + const { onCopy, hasCopied } = useClipboard(value); + return ( + + + {label} + + + {value} + + + + ); +} + +export default function AnchorResultPanel({ + result, + onDone, +}: { + result: AnchorResult; + onDone: () => void; +}) { + return ( + + {result.broadcast ? ( + + + + Anchored + + The declaration is now a permanent on-chain record. + + + + ) : ( + + + + Built & signed — not broadcast + + Nothing was sent. Decode the hex below to verify before using + this in production. + + + + )} + + + {result.broadcast ? ( + <> + + + + ) : ( + <> + + + + )} + + + + + + + ); +} diff --git a/packages/app/src/connect/__tests__/protocol.test.ts b/packages/app/src/connect/__tests__/protocol.test.ts index c62578b..dcd0060 100644 --- a/packages/app/src/connect/__tests__/protocol.test.ts +++ b/packages/app/src/connect/__tests__/protocol.test.ts @@ -9,6 +9,10 @@ import { parseSignRequest, parseConnectRequest, isRecognizedConnectChallenge, + parseCanonDeclaration, + canonDeclarationFromDocument, + buildAnchorResult, + buildAnchorCallbackUrl, buildCallbackUrl, buildSignResult, encodeSignResult, @@ -1594,3 +1598,153 @@ describe("buildErrorCallbackUrl", () => { expect(url).toBeUndefined(); }); }); + +describe("parseCanonDeclaration", () => { + const REF = "ab".repeat(32) + "00000000"; + const MSG = + "canon-declaration|v1|radiant-mainnet|signer=14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i|" + + "issued=2026-09-02T14:43:29.677Z|expires=2027-12-31T00:00:00.000Z|" + + `declares=creator:${REF}:CraigD%20Profile|revokes=-|comment=-`; + + it("parses the canonical single-line message", () => { + const parsed = parseCanonDeclaration(MSG); + expect(parsed).toBeDefined(); + expect(parsed!.version).toBe(1); + expect(parsed!.network).toBe("radiant-mainnet"); + expect(parsed!.signer).toBe("14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i"); + expect(parsed!.declares).toEqual([ + { kind: "creator", ref: REF, label: "CraigD Profile" }, + ]); + expect(parsed!.revokes).toEqual([]); + expect(parsed!.expires).toBe("2027-12-31T00:00:00.000Z"); + expect(parsed!.comment).toBeUndefined(); + }); + + it("keeps the terminal comment whole, pipes included", () => { + const parsed = parseCanonDeclaration( + MSG.replace("comment=-", "comment=a|b|c") + ); + expect(parsed!.comment).toBe("a|b|c"); + }); + + it("parses revocations and no-expiry", () => { + const parsed = parseCanonDeclaration( + "canon-declaration|v1|radiant-mainnet|signer=14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i|" + + "issued=2026-09-02T14:43:29.677Z|expires=never|declares=|" + + `revokes=${REF}|comment=-` + ); + expect(parsed!.declares).toEqual([]); + expect(parsed!.revokes).toEqual([REF]); + expect(parsed!.expires).toBeUndefined(); + }); + + it("rejects everything that is not the exact shape", () => { + expect(parseCanonDeclaration("just some text")).toBeUndefined(); + expect(parseCanonDeclaration("")).toBeUndefined(); + // Wrong magic, missing fields, bad kind, bad ref, empty document. + expect(parseCanonDeclaration(MSG.replace("canon-declaration", "canon"))).toBeUndefined(); + expect(parseCanonDeclaration(MSG.replace("|revokes=-", ""))).toBeUndefined(); + expect(parseCanonDeclaration(MSG.replace("creator:", "owner:"))).toBeUndefined(); + // "work" is a valid third kind (standalone NFTs with nothing to derive from). + expect(parseCanonDeclaration(MSG.replace("creator:", "work:"))?.declares[0]?.kind).toBe("work"); + expect(parseCanonDeclaration(MSG.replace(REF, "ff".repeat(10)))).toBeUndefined(); + expect( + parseCanonDeclaration(MSG.replace(`declares=creator:${REF}:CraigD%20Profile`, "declares=")) + ).toBeUndefined(); + // A recognized wallet-connect challenge is not a declaration. + expect( + parseCanonDeclaration("glyphgalaxy:wallet-connect:v1:sess:nonce") + ).toBeUndefined(); + }); + + it("v1 is display-recognition only; v2 also matches the connect badge", () => { + expect(isRecognizedConnectChallenge(MSG)).toBe(false); + expect(parseCanonDeclaration(MSG)).toBeDefined(); + const V2 = + "canon-declaration:wallet-connect:v2:radiant-mainnet:" + + "signer=14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i|issued=2026-09-02T14:43:29.677Z|" + + `expires=never|declares=creator:${REF}:CraigD%20Profile|revokes=-|comment=a|b`; + expect(isRecognizedConnectChallenge(V2)).toBe(true); + const parsed = parseCanonDeclaration(V2); + expect(parsed).toBeDefined(); + expect(parsed!.version).toBe(2); + expect(parsed!.declares[0]!.label).toBe("CraigD Profile"); + expect(parsed!.expires).toBeUndefined(); + expect(parsed!.comment).toBe("a|b"); + // The nonce slot echoes just the network — short and harmless. + expect(extractChallengeNonce(V2)).toBe("radiant-mainnet"); + }); +}); + +describe("anchor-request", () => { + const REF2 = "cd".repeat(32) + "00000000"; + const DOC = JSON.stringify({ + format: "canon-declaration", + version: 2, + network: "radiant-mainnet", + signer: "14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i", + declares: [{ kind: "creator", ref: REF2, label: "CraigD Profile" }], + issuedAt: "2026-09-02T14:43:29.677Z", + signature: "IF9v", + }); + + it("canonDeclarationFromDocument rebuilds the v2 challenge", () => { + const out = canonDeclarationFromDocument(DOC); + expect(out).toBeDefined(); + expect(out!.challenge).toBe( + "canon-declaration:wallet-connect:v2:radiant-mainnet:" + + "signer=14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i|issued=2026-09-02T14:43:29.677Z|" + + `expires=never|declares=creator:${REF2}:CraigD%20Profile|revokes=-|comment=-` + ); + expect(out!.declaration.version).toBe(2); + expect(out!.signature).toBe("IF9v"); + }); + + it("rejects malformed documents", () => { + expect(canonDeclarationFromDocument("{not json")).toBeUndefined(); + expect(canonDeclarationFromDocument(DOC.replace('"version":2', '"version":3'))).toBeUndefined(); + expect(canonDeclarationFromDocument(DOC.replace(REF2, "beef"))).toBeUndefined(); + expect(canonDeclarationFromDocument(DOC.replace('"signature":"IF9v"', '"signature":5'))).toBeUndefined(); + }); + + it("envelope round-trips through parseConnectRequest", () => { + const parsed = parseConnectRequest( + JSON.stringify({ + protocol: "photonic-connect", + v: 1, + t: "anchor-request", + document: DOC, + origin: "https://canon.rxd.zone", + callback: "https://canon.rxd.zone/declaration", + }) + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.request.t).toBe("anchor-request"); + if (parsed.request.t !== "anchor-request") return; + expect(parsed.request.document).toBe(DOC); + expect(parsed.request.broadcast).toBe(true); + expect(parsed.request.callback).toBe("https://canon.rxd.zone/declaration"); + }); + + it("refuses an envelope whose document is not a signed declaration", () => { + const parsed = parseConnectRequest( + JSON.stringify({ protocol: "photonic-connect", v: 1, t: "anchor-request", document: "{}" }) + ); + expect(parsed.ok).toBe(false); + }); + + it("builds results and callback URLs", () => { + const result = buildAnchorResult( + { id: "x1" }, + { broadcast: true, docHash: "ab".repeat(32), commitTxid: "11".repeat(32), revealTxid: "22".repeat(32) } + ); + expect(result.t).toBe("anchor-result"); + const url = buildAnchorCallbackUrl( + { callback: "https://canon.rxd.zone/declaration" }, + result + ); + expect(url).toContain("#id=x1&broadcast=true&docHash="); + expect(url).toContain("revealTxid=" + "22".repeat(32)); + }); +}); diff --git a/packages/app/src/connect/anchorFlow.ts b/packages/app/src/connect/anchorFlow.ts new file mode 100644 index 0000000..9fc6ed1 --- /dev/null +++ b/packages/app/src/connect/anchorFlow.ts @@ -0,0 +1,199 @@ +/** + * Non-React glue for the connect `anchor-request` flow: publishing a signed + * Canon declaration on-chain as a `cnd1` commit+reveal pair, self-funded from + * the wallet's own RXD UTXOs (never dApp-specified inputs — same rule as + * minting). + * + * The carrier mirrors a Glyph mint's mechanics with a minimal, non-token + * commit script: `OP_HASH256 OP_EQUALVERIFY OP_DUP + * OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG`, revealed by a + * push-only scriptSig ` ` where the payload is + * `"cnd1" ‖ the document's exact JSON bytes`. Nothing is minted; the wallet + * only pays the fee (the document certifies itself through the signmessage + * signature inside it — verified here before anything is built, because an + * anchor is permanent). Broadcast order and the missing-inputs retry mirror + * `mintFlow.ts`. + */ +import { Buffer } from "buffer"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; +import rjs from "@radiant-core/radiantjs"; +import db from "@app/db"; +import { electrumWorker } from "@app/electrum/Electrum"; +import { feeRate as feeRateSignal } from "@app/signals"; +import { ContractType } from "@app/types"; +import { updateRxdBalances } from "@app/utxos"; +import { buildTx } from "@lib/tx"; +import { fundTx } from "@lib/coinSelect"; +import { p2pkhScript, pushDataSize } from "@lib/script"; +import { normalizeFeeRate } from "@lib/feePolicy"; +import { verifyMessage } from "@lib/sign"; +import { canonDeclarationFromDocument } from "@app/connect/protocol"; +import type { AnchorRequest } from "@app/connect/protocol"; +import type { UnfinalizedInput, UnfinalizedOutput } from "@lib/types"; + +const { Address, Script } = rjs; + +export class AnchorRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "AnchorRequestError"; + } +} + +const ANCHOR_MAGIC = Uint8Array.from([0x63, 0x6e, 0x64, 0x31]); // "cnd1" +const DUST_PHOTONS = 546; + +function sha256d(bytes: Uint8Array): Uint8Array { + return sha256(sha256(bytes)); +} + +/** `"cnd1" ‖ exact document bytes` and its sha256d anchor identity. */ +export function anchorPayload(document: string): { payload: Uint8Array; docHash: string } { + const docBytes = new TextEncoder().encode(document); + const payload = new Uint8Array(ANCHOR_MAGIC.length + docBytes.length); + payload.set(ANCHOR_MAGIC, 0); + payload.set(docBytes, ANCHOR_MAGIC.length); + return { payload, docHash: bytesToHex(sha256d(payload)) }; +} + +/** + * `OP_HASH256 OP_EQUALVERIFY OP_DUP OP_HASH160 + * OP_EQUALVERIFY OP_CHECKSIG` — hex. No ref opcodes; nothing is minted. + */ +export function anchorCommitScript(docHashHex: string, address: string): string { + const pkh = bytesToHex(Address.fromString(address).hashBuffer); + return `aa20${docHashHex}8876a914${pkh}88ac`; +} + +export interface AnchorOutcome { + broadcast: boolean; + docHash: string; + commitTxid?: string; + revealTxid?: string; + commitHex?: string; + revealHex?: string; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function isMissingInputsError(error: unknown): boolean { + return error instanceof Error && /missing inputs/i.test(error.message); +} + +export async function anchorFromRequest( + req: AnchorRequest, + wif: string, + address: string +): Promise { + const parsed = canonDeclarationFromDocument(req.document); + if (!parsed) { + throw new AnchorRequestError("document is not a well-formed signed Canon declaration"); + } + // An anchor is permanent — never publish a document whose signature does + // not verify against the signer address inside it. + if (!verifyMessage(parsed.challenge, parsed.declaration.signer, parsed.signature)) { + throw new AnchorRequestError( + "the declaration's signature does not verify against its signer address" + ); + } + + const { payload, docHash } = anchorPayload(req.document); + const commitScript = anchorCommitScript(docHash, address); + const p2pkh = p2pkhScript(address); + const feeRate = normalizeFeeRate(req.feeRate ?? feeRateSignal.value); + + // Reveal: one input (outpoint 36 + scriptSig [72+1 sig, 33+1 pubkey, + // payload push] + varints + sequence), one dust output back to the wallet. + const revealScriptSigSize = 73 + 34 + pushDataSize(payload.length) + payload.length; + const revealSize = 4 + 1 + 36 + 3 + revealScriptSigSize + 4 + 1 + 8 + 1 + 25 + 4; + const revealFee = Math.ceil(revealSize * feeRate); + const commitValue = DUST_PHOTONS + revealFee; + + try { + await electrumWorker.value.manualSync(); + } catch (error) { + console.debug("[anchorFlow] pre-anchor UTXO refresh failed", error); + } + const coins = await db.txo + .where({ contractType: ContractType.RXD, spent: 0 }) + .toArray(); + + const commitOutputs: UnfinalizedOutput[] = [{ script: commitScript, value: commitValue }]; + const inputs: UnfinalizedInput[] = []; + const { funding, change, fee } = fundTx(address, coins, inputs, commitOutputs, p2pkh, feeRate); + if (fee === 0) { + throw new AnchorRequestError("insufficient RXD to fund the anchor transactions"); + } + inputs.push(...funding); + + const commitTx = buildTx(address, wif, inputs, commitOutputs.concat(change), false); + + const revealTx = buildTx( + address, + wif, + [{ txid: commitTx.id, vout: 0, script: commitScript, value: commitValue }], + [{ script: p2pkh, value: DUST_PHOTONS }], + false, + // The payload push goes LAST so it is on top of the stack when the commit + // script's OP_HASH256 runs; the sig+pubkey below it feed the p2pkh tail. + (_index, spendScript) => spendScript.add(Buffer.from(payload)), + undefined, + // The reveal deliberately pays exactly for its own bytes; the payload can + // make the pre-sized fee look high to the generic overpay heuristic. + true + ); + + if (req.broadcast === false) { + return { + broadcast: false, + docHash, + commitHex: commitTx.toString(), + revealHex: revealTx.toString(), + }; + } + + const commitTxid = await electrumWorker.value.broadcast(commitTx.toString()); + try { + await db.broadcast.put({ txid: commitTxid, date: Date.now(), description: "canon_anchor" }); + } catch (error) { + console.error("[anchorFlow] failed to log commit broadcast (commit already succeeded)", error); + } + try { + await electrumWorker.value.manualSync(); + } catch (error) { + console.debug("[anchorFlow] post-commit UTXO refresh failed", error); + } + try { + await updateRxdBalances(address); + } catch (error) { + console.error("[anchorFlow] post-commit balance refresh failed", error); + } + + let revealTxid: string; + try { + revealTxid = await electrumWorker.value.broadcast(revealTx.toString()); + } catch (error) { + if (!isMissingInputsError(error)) { + throw new AnchorRequestError( + `commit broadcast as ${commitTxid}, but the reveal failed to broadcast: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + console.debug("[anchorFlow] reveal returned Missing inputs; refreshing and retrying"); + await electrumWorker.value.manualSync(); + await wait(1500); + revealTxid = await electrumWorker.value.broadcast(revealTx.toString()); + } + + try { + await db.broadcast.put({ txid: revealTxid, date: Date.now(), description: "canon_anchor" }); + await electrumWorker.value.manualSync(); + await updateRxdBalances(address); + } catch (error) { + console.debug("[anchorFlow] post-reveal bookkeeping failed (anchor succeeded)", error); + } + + return { broadcast: true, docHash, commitTxid, revealTxid }; +} diff --git a/packages/app/src/connect/protocol.ts b/packages/app/src/connect/protocol.ts index 4116ba8..a43fbfa 100644 --- a/packages/app/src/connect/protocol.ts +++ b/packages/app/src/connect/protocol.ts @@ -141,6 +141,41 @@ export type PsbtSignResult = { complete: boolean; }; +export type AnchorRequest = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "anchor-request"; + /** + * The SIGNED Canon declaration document, as the exact JSON string. The + * string's bytes are the anchor identity (docHash = sha256d("cnd1" + doc)), + * so it is carried and committed verbatim - never re-serialized. + */ + document: string; + /** Override the wallet's current fee rate (photons/byte), if provided. */ + feeRate?: number; + /** Only the literal `false` opts out of broadcasting - mint-request rules. */ + broadcast?: boolean; + id?: string; + origin?: string; + app?: string; + callback?: string; +}; + +export type AnchorResult = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "anchor-result"; + id?: string; + broadcast: boolean; + /** sha256d("cnd1" + document bytes), hex - the document's anchor identity. */ + docHash: string; + commitTxid?: string; + revealTxid?: string; + /** Present when `broadcast: false` - nothing was sent. */ + commitHex?: string; + revealHex?: string; +}; + /** An embedded file: raw bytes carried inline, base64-encoded on the wire. */ export type MintEmbeddedFile = { /** MIME type; must be one of {@link MINT_ALLOWED_MIME_TYPES}. */ @@ -309,6 +344,7 @@ export type SwapCancelResult = { export type ConnectRequest = | SignRequest | PsbtSignRequest + | AnchorRequest | MintRequest | SwapOfferRequest | SwapAcceptRequest @@ -878,6 +914,159 @@ function normalizeSwapCancelEnvelope(obj: Record): ParsedReques }; } +/** 16 KiB payload cap minus the 4-byte cnd1 magic (Canon anchor scanner cap). */ +export const MAX_ANCHOR_DOCUMENT_LEN = 16 * 1024 - 4; + +/** + * Validate a Canon declaration DOCUMENT (the JSON string an anchor-request + * carries) and rebuild its canonical signed message. Returns undefined for + * anything that is not a well-formed signed declaration - anchoring is + * permanent, so a document this wallet cannot understand end-to-end is never + * anchored. Signature verification happens in the flow (needs crypto); this + * is the pure part. + */ +export function canonDeclarationFromDocument( + json: string +): { challenge: string; declaration: CanonDeclarationChallenge; signature: string } | undefined { + if (typeof json !== "string" || json.length > MAX_ANCHOR_DOCUMENT_LEN) return undefined; + let doc: Record; + try { + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + doc = parsed as Record; + } catch { + return undefined; + } + if (doc.format !== "canon-declaration") return undefined; + const version = doc.version; + if (version !== 1 && version !== 2) return undefined; + if (typeof doc.network !== "string" || typeof doc.signer !== "string") return undefined; + if (typeof doc.issuedAt !== "string" || typeof doc.signature !== "string") return undefined; + if (!Array.isArray(doc.declares)) return undefined; + + const declares: string[] = []; + for (const raw of doc.declares) { + const entry = raw as Record; + if (typeof entry?.kind !== "string" || typeof entry?.ref !== "string") return undefined; + const label = entry.label === undefined ? "" : entry.label; + if (typeof label !== "string") return undefined; + declares.push(`${entry.kind}:${entry.ref}:${encodeURIComponent(label)}`); + } + const revokesArr = doc.revokes === undefined ? [] : doc.revokes; + if (!Array.isArray(revokesArr) || revokesArr.some((r) => typeof r !== "string")) { + return undefined; + } + const revokes = revokesArr.length > 0 ? revokesArr.join(",") : "-"; + const comment = + doc.comment === undefined || doc.comment === "" ? "-" : (doc.comment as unknown); + if (typeof comment !== "string") return undefined; + const expires = doc.expiresAt === undefined ? "never" : doc.expiresAt; + if (typeof expires !== "string") return undefined; + + const body = [ + `signer=${doc.signer}`, + `issued=${doc.issuedAt}`, + `expires=${expires}`, + `declares=${declares.join(",")}`, + `revokes=${revokes}`, + `comment=${comment}`, + ].join("|"); + const challenge = + version === 1 + ? ["canon-declaration", "v1", doc.network, body].join("|") + : `canon-declaration:wallet-connect:v2:${doc.network}:${body}`; + + // The declaration parser is the single validation authority - the message + // must round-trip through it (refs, kinds, dates, signer shape). + const declaration = parseCanonDeclaration(challenge); + if (!declaration) return undefined; + if (challenge.length > MAX_MESSAGE_LENGTH || hasControlChars(challenge)) return undefined; + return { challenge, declaration, signature: doc.signature }; +} + +function normalizeAnchorEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; + + if (typeof obj.document !== "string") { + return { ok: false, error: "request is missing the declaration document" }; + } + if (!canonDeclarationFromDocument(obj.document)) { + return { + ok: false, + error: + "document is not a well-formed signed Canon declaration - refusing (anchoring is permanent)", + }; + } + if (obj.feeRate !== undefined) { + if (typeof obj.feeRate !== "number" || !Number.isFinite(obj.feeRate) || obj.feeRate <= 0) { + return { ok: false, error: "feeRate must be a positive number" }; + } + } + const origin = cleanOrigin(obj.origin); + return { + ok: true, + request: { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "anchor-request", + document: obj.document, + feeRate: typeof obj.feeRate === "number" ? obj.feeRate : undefined, + broadcast: obj.broadcast === false ? false : true, + id: cleanString(obj.id, MAX_ID_LEN), + origin, + app: cleanString(obj.app, MAX_LABEL_LEN), + callback: cleanCallback(obj.callback, origin), + }, + }; +} + +export function buildAnchorResult( + req: Pick, + out: { + broadcast: boolean; + docHash: string; + commitTxid?: string; + revealTxid?: string; + commitHex?: string; + revealHex?: string; + } +): AnchorResult { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "anchor-result", + ...(req.id ? { id: req.id } : {}), + broadcast: out.broadcast, + docHash: out.docHash, + ...(out.commitTxid !== undefined ? { commitTxid: out.commitTxid } : {}), + ...(out.revealTxid !== undefined ? { revealTxid: out.revealTxid } : {}), + ...(out.commitHex !== undefined ? { commitHex: out.commitHex } : {}), + ...(out.revealHex !== undefined ? { revealHex: out.revealHex } : {}), + }; +} + +export function buildAnchorCallbackUrl( + req: Pick, + result: Pick< + AnchorResult, + "id" | "broadcast" | "docHash" | "commitTxid" | "revealTxid" + > +): string | undefined { + return composeCallbackUrl(req.callback, [ + ...optionalParam("id", result.id), + ["broadcast", String(result.broadcast)], + ["docHash", result.docHash], + ...optionalParam("commitTxid", result.commitTxid), + ...optionalParam("revealTxid", result.revealTxid), + ]); +} + +/** Serialize an anchor result for the response QR / copy box. */ +export function encodeAnchorResult(result: AnchorResult): string { + return JSON.stringify(result, null, 2); +} + function normalizeEnvelope(obj: Record): ParsedRequest { const t = obj.t; if ( @@ -885,6 +1074,7 @@ function normalizeEnvelope(obj: Record): ParsedRequest { t !== "sign-request" && t !== "psbt-sign-request" && t !== "mint-request" && + t !== "anchor-request" && t !== "swap-offer-request" && t !== "swap-accept-request" && t !== "swap-cancel-request" @@ -893,6 +1083,7 @@ function normalizeEnvelope(obj: Record): ParsedRequest { } if (t === "psbt-sign-request") return normalizePsbtEnvelope(obj); if (t === "mint-request") return normalizeMintEnvelope(obj); + if (t === "anchor-request") return normalizeAnchorEnvelope(obj); if (t === "swap-offer-request") return normalizeSwapOfferEnvelope(obj); if (t === "swap-accept-request") return normalizeSwapAcceptEnvelope(obj); if (t === "swap-cancel-request") return normalizeSwapCancelEnvelope(obj); @@ -958,6 +1149,142 @@ export function isRecognizedConnectChallenge(challenge: string): boolean { return typeof challenge === "string" && CONNECT_CHALLENGE_RE.test(challenge); } +// --------------------------------------------------------------------------- +// Canon declarations (canon.rxd.zone) +// +// Canon's signed creator declarations arrive as sign-requests whose challenge +// is the canonical single-line declaration message (canon.rxd docs/ +// DECLARATIONS.md). Recognizing the shape lets the approval screen show the +// signer WHAT they are declaring — structured, not an opaque string — and +// skip the "not a standard connect request" warning for a format this wallet +// understands. Parsing here is display-only: the signature always covers the +// raw challenge verbatim, and a challenge that fails this parser is simply +// not a Canon declaration (never an error). +// --------------------------------------------------------------------------- + +export type CanonDeclarationEntry = { + kind: "container" | "creator" | "work"; + /** 72-hex display-form ref. */ + ref: string; + /** Decoded label — UNTRUSTED display text; sanitize before rendering. */ + label: string; +}; + +export type CanonDeclarationChallenge = { + version: number; + network: string; + signer: string; + issued: string; + /** ISO date-time, or undefined for `never`. */ + expires?: string; + declares: CanonDeclarationEntry[]; + revokes: string[]; + /** UNTRUSTED display text; sanitize before rendering. */ + comment?: string; +}; + +const CANON_REF_RE = /^[0-9a-f]{72}$/; +const CANON_NETWORK_RE = /^[a-z0-9-]{1,32}$/; +// Base58check P2PKH address shape (charset + length only; no checksum here — +// display-side recognition, not validation). +const CANON_SIGNER_RE = /^[1-9A-HJ-NP-Za-km-z]{26,35}$/; + +/** + * Parse a Canon declaration challenge, or return undefined for anything else. + * + * Both message versions are injective by construction (fixed field order, + * url-encoded labels, terminal comment): + * - v1: `canon-declaration|v1||` (pipe-delimited head) + * - v2: `canon-declaration:wallet-connect:v2::` — the + * recognized connect-challenge shape, so v2 also earns the standard badge. + * The body is `signer=…|issued=…|expires=…|declares=kind:ref:label,…| + * revokes=…|comment=…`; the comment is TERMINAL and may itself contain `|`, + * so the split is bounded: five fixed fields, remainder = comment. + */ +export function parseCanonDeclaration( + challenge: string +): CanonDeclarationChallenge | undefined { + if (typeof challenge !== "string" || challenge.length > MAX_MESSAGE_LENGTH) { + return undefined; + } + let version: number; + let network: string; + let body: string; + const v2 = /^canon-declaration:wallet-connect:v(\d{1,3}):([a-z0-9-]{1,32}):([\s\S]+)$/.exec( + challenge + ); + if (v2) { + version = parseInt(v2[1] as string, 10); + network = v2[2] as string; + body = v2[3] as string; + } else { + const v1 = /^canon-declaration\|v(\d{1,3})\|([a-z0-9-]{1,32})\|([\s\S]+)$/.exec(challenge); + if (!v1) return undefined; + version = parseInt(v1[1] as string, 10); + network = v1[2] as string; + body = v1[3] as string; + } + if (!CANON_NETWORK_RE.test(network)) return undefined; + + const parts = body.split("|"); + if (parts.length < 6) return undefined; + const [signerF, issuedF, expiresF, declaresF, revokesF] = parts as [ + string, string, string, string, string, + ]; + const commentF = parts.slice(5).join("|"); + if (!signerF.startsWith("signer=") || !issuedF.startsWith("issued=")) return undefined; + if (!expiresF.startsWith("expires=") || !declaresF.startsWith("declares=")) return undefined; + if (!revokesF.startsWith("revokes=") || !commentF.startsWith("comment=")) return undefined; + + const signer = signerF.slice("signer=".length); + if (!CANON_SIGNER_RE.test(signer)) return undefined; + const issued = issuedF.slice("issued=".length); + if (Number.isNaN(Date.parse(issued))) return undefined; + const expiresRaw = expiresF.slice("expires=".length); + if (expiresRaw !== "never" && Number.isNaN(Date.parse(expiresRaw))) return undefined; + + const declares: CanonDeclarationEntry[] = []; + const declaresRaw = declaresF.slice("declares=".length); + if (declaresRaw.length > 0) { + for (const entry of declaresRaw.split(",")) { + const fields = entry.split(":"); + if (fields.length !== 3) return undefined; + const [kind, ref, encodedLabel] = fields as [string, string, string]; + if (kind !== "container" && kind !== "creator" && kind !== "work") return undefined; + if (!CANON_REF_RE.test(ref)) return undefined; + let label: string; + try { + label = decodeURIComponent(encodedLabel); + } catch { + return undefined; + } + declares.push({ kind, ref, label }); + } + } + + const revokesRaw = revokesF.slice("revokes=".length); + const revokes: string[] = []; + if (revokesRaw !== "-") { + for (const ref of revokesRaw.split(",")) { + if (!CANON_REF_RE.test(ref)) return undefined; + revokes.push(ref); + } + } + if (declares.length === 0 && revokes.length === 0) return undefined; + + const comment = commentF.slice("comment=".length); + return { + version, + network, + signer, + issued, + ...(expiresRaw !== "never" ? { expires: expiresRaw } : {}), + declares, + revokes, + ...(comment !== "-" ? { comment } : {}), + }; +} + /** Build a {@link SignResult} from a request + a produced signature. */ export function buildSignResult( req: Pick, diff --git a/packages/app/src/pages/Connect.tsx b/packages/app/src/pages/Connect.tsx index 652b45f..a376597 100644 --- a/packages/app/src/pages/Connect.tsx +++ b/packages/app/src/pages/Connect.tsx @@ -65,6 +65,8 @@ import PsbtRequestPanel from "@app/components/connect/PsbtRequestPanel"; import PsbtResultPanel from "@app/components/connect/PsbtResultPanel"; import MintRequestPanel from "@app/components/connect/MintRequestPanel"; import MintResultPanel from "@app/components/connect/MintResultPanel"; +import AnchorRequestPanel from "@app/components/connect/AnchorRequestPanel"; +import AnchorResultPanel from "@app/components/connect/AnchorResultPanel"; import SwapOfferRequestPanel from "@app/components/connect/SwapOfferRequestPanel"; import SwapOfferResultPanel from "@app/components/connect/SwapOfferResultPanel"; import SwapAcceptRequestPanel from "@app/components/connect/SwapAcceptRequestPanel"; @@ -85,6 +87,8 @@ import { hasUnsafeDisplayChars, sanitizeForDisplay } from "@lib/displayText"; import { buildCallbackUrl, buildErrorCallbackUrl, + buildAnchorCallbackUrl, + buildAnchorResult, buildMintCallbackUrl, buildMintResult, buildPsbtCallbackUrl, @@ -100,8 +104,11 @@ import { classifyConnectError, encodeSignResult, isRecognizedConnectChallenge, + parseCanonDeclaration, parseConnectRequest, type ConnectErrorCode, + type AnchorRequest, + type AnchorResult, type MintRequest, type MintResult, type PsbtSignRequest, @@ -117,6 +124,7 @@ import { } from "@app/connect/protocol"; import { enrichPsbt, signAndMaybeBroadcast, type EnrichedPsbt } from "@app/connect/psbtFlow"; import { mintFromRequest } from "@app/connect/mintFlow"; +import { anchorFromRequest } from "@app/connect/anchorFlow"; import { acceptSwapOffer, cancelSwapOffer, createSwapOffer } from "@app/connect/swapFlow"; /** @@ -146,6 +154,8 @@ export default function Connect() { const [psbtBusy, setPsbtBusy] = useState(false); const [mintResult, setMintResult] = useState(null); const [mintBusy, setMintBusy] = useState(false); + const [anchorResult, setAnchorResult] = useState(null); + const [anchorBusy, setAnchorBusy] = useState(false); const [swapOfferResult, setSwapOfferResult] = useState(null); const [swapOfferBusy, setSwapOfferBusy] = useState(false); const [swapAcceptResult, setSwapAcceptResult] = useState(null); @@ -466,6 +476,47 @@ export default function Connect() { [toast, fromDeepLink, fireConnectError] ); + const anchorSign = useCallback( + async (req: AnchorRequest) => { + setAnchorBusy(true); + try { + const outcomePromise = withWif((wif) => + anchorFromRequest(req, wif, wallet.value.address) + ); + if (!outcomePromise) { + toast({ + status: "error", + title: "Wallet is locked — unable to anchor", + }); + fireConnectError(req, "locked", "Wallet is locked — unable to anchor"); + return; + } + const outcome = await outcomePromise; + const anchorResultValue = buildAnchorResult(req, outcome); + setAnchorResult(anchorResultValue); + + // Same dry-run rule as minting: only navigate away when something + // was actually sent. + const callbackUrl = + outcome.broadcast && canAutoReturn(fromDeepLink) + ? buildAnchorCallbackUrl(req, anchorResultValue) + : undefined; + if (callbackUrl) window.location.assign(callbackUrl); + } catch (err) { + toast({ + status: "error", + title: "Unable to anchor", + description: err instanceof Error ? err.message : String(err), + }); + const { code, message } = classifyConnectError(err); + fireConnectError(req, code, message); + } finally { + setAnchorBusy(false); + } + }, + [toast, fromDeepLink, fireConnectError] + ); + const onApprove = useCallback(() => { if (!request) return; const doSign = () => { @@ -484,6 +535,8 @@ export default function Connect() { else release(); } else if (request.t === "mint-request") { void mintSign(request).finally(release); + } else if (request.t === "anchor-request") { + void anchorSign(request).finally(release); } else if (request.t === "swap-offer-request") { void swapOfferSign(request).finally(release); } else if (request.t === "swap-accept-request") { @@ -515,6 +568,7 @@ export default function Connect() { psbtSign, psbtParse, mintSign, + anchorSign, swapOfferSign, swapAcceptSign, swapCancelSign, @@ -526,6 +580,7 @@ export default function Connect() { setPsbtResult(null); setEnriched(null); setMintResult(null); + setAnchorResult(null); setSwapOfferResult(null); setSwapAcceptResult(null); setSwapCancelResult(null); @@ -552,6 +607,7 @@ export default function Connect() { const isPsbtRequest = request?.t === "psbt-sign-request"; const isMintRequest = request?.t === "mint-request"; + const isAnchorRequest = request?.t === "anchor-request"; const isSwapOfferRequest = request?.t === "swap-offer-request"; const isSwapAcceptRequest = request?.t === "swap-accept-request"; const isSwapCancelRequest = request?.t === "swap-cancel-request"; @@ -566,6 +622,8 @@ export default function Connect() { ? "Review and approve a transaction an app is asking you to sign." : isMintRequest ? "Review and approve an NFT an app is asking you to mint." + : isAnchorRequest + ? "Review and approve publishing a signed declaration on-chain." : isSwapOfferRequest ? "Review and approve listing an item for sale." : isSwapAcceptRequest @@ -625,6 +683,15 @@ export default function Connect() { onApprove={onApprove} onReject={onReject} /> + ) : anchorResult ? ( + + ) : request?.t === "anchor-request" ? ( + ) : swapOfferResult ? ( ) : request?.t === "swap-offer-request" ? ( @@ -777,6 +844,10 @@ function RequestPanel({ onReject: () => void; }) { const recognized = isRecognizedConnectChallenge(request.challenge); + // A Canon creator declaration (canon.rxd.zone): a format this wallet + // understands, rendered structured below so the signer sees WHAT they are + // declaring. Display-only — the signature still covers the raw challenge. + const canonDeclaration = parseCanonDeclaration(request.challenge); // Bidi overrides and invisible characters can make this screen read as one // thing while the signature covers another. They are replaced below; this // says so, because a silently cleaned string is still a lie by omission. @@ -792,7 +863,16 @@ function RequestPanel({ Signature request - {recognized ? ( + {canonDeclaration ? ( + + Canon declaration + + ) : recognized ? ( )} - {!recognized && ( + {!recognized && !canonDeclaration && ( @@ -847,6 +927,68 @@ function RequestPanel({ )} + {canonDeclaration && ( + + + You are declaring + + + {canonDeclaration.declares.map((entry) => ( + + + This key recognizes the{" "} + + {entry.kind === "container" + ? "collection" + : entry.kind === "work" + ? "individual work" + : "creator token"} + + {entry.label + ? ` “${sanitizeForDisplay(entry.label)}”` + : ""} + + + {entry.ref} + + + ))} + {canonDeclaration.revokes.map((ref) => ( + + + This key withdraws recognition of + + + {ref} + + + ))} + + Network {sanitizeForDisplay(canonDeclaration.network)} · dated{" "} + {sanitizeForDisplay(canonDeclaration.issued)} ·{" "} + {canonDeclaration.expires + ? `expires ${sanitizeForDisplay(canonDeclaration.expires)}` + : "no expiry"} + + {canonDeclaration.comment ? ( + + “{sanitizeForDisplay(canonDeclaration.comment)}” + + ) : null} + + Published declarations are permanent. The exact signed text is + shown below. + + + + )} + {hiddenFormatting && (