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}
+
+ : }
+ onClick={onCopy}
+ variant="ghost"
+ >
+ {hasCopied ? "Copied!" : "Copy"}
+
+
+ );
+}
+
+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 && (