))}
diff --git a/frontend/src/page/Treasure.jsx b/frontend/src/page/Treasure.jsx
index c83301a1..c7c5b857 100644
--- a/frontend/src/page/Treasure.jsx
+++ b/frontend/src/page/Treasure.jsx
@@ -16,6 +16,8 @@ import {
} from "lucide-react";
import { motion } from "framer-motion";
import toast from "react-hot-toast";
+import { cn } from "@/lib/utils";
+import { SHELL } from "@/utils/layout";
const Treasure = () => {
const [treasureAmount, setTreasureAmount] = useState(0);
@@ -174,7 +176,7 @@ const Treasure = () => {
};
return (
-
+
`. The prefix is matched
+ * exactly on decode so a future format can change the encoding without older
+ * tokens being misread as the new one — a silent misparse here would surface
+ * as an unverifiable invoice rather than an obvious error.
+ */
+const TOKEN_PREFIX = 'cv1.';
+const ENVELOPE_TYPE = 'invoice';
+const ENVELOPE_VERSION = 1;
+
+/**
+ * Practical size ceilings, in characters of the finished URL.
+ *
+ * Neither is a spec limit. Browsers accept far longer URLs than
+ * SHARE_URL_MAX_CHARS, and a version-40 QR code holds 2,953 bytes — but chat
+ * clients wrap or truncate long links, and a maximally dense QR is
+ * unreliable in front of a phone camera. These are the points past which the
+ * UI should offer the file fallback instead.
+ */
+const SHARE_URL_MAX_CHARS = 2000;
+const SHARE_QR_MAX_CHARS = 1500;
+
+/** Thrown for every malformed or unsupported token, with a `code` to switch on. */
+export class InvoiceShareError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'InvoiceShareError';
+ this.code = code;
+ }
+}
+
+/**
+ * Serialise an envelope to JSON.
+ *
+ * BigInts are stringified rather than thrown on, matching `encryptPayload` —
+ * invoice amounts and ids reach this layer as either strings or BigInts
+ * depending on whether they came from a form or from a contract read.
+ */
+function toJson(envelope) {
+ return JSON.stringify(envelope, (_, v) =>
+ typeof v === 'bigint' ? v.toString() : v
+ );
+}
+
+/**
+ * Pack an invoice into a share token.
+ *
+ * `invoiceData` must be byte-for-byte the object that was hashed into the
+ * on-chain `invoiceDataHash`. It is carried through untouched: the envelope
+ * wraps it rather than merging into it, because any field added, renamed or
+ * dropped inside it changes the hash and makes the token unverifiable on the
+ * far side.
+ *
+ * @param {Object} params
+ * @param {number|string|bigint} params.invoiceId - on-chain invoice id
+ * @param {number|string} params.chainId
+ * @param {Object} params.invoiceData - the payload exactly as hashed
+ * @returns {string} share token
+ */
+export function encodeInvoiceShare({ invoiceId, chainId, invoiceData }) {
+ const numericChainId = Number(chainId);
+ if (!Number.isFinite(numericChainId) || numericChainId <= 0) {
+ throw new InvoiceShareError(
+ 'INVALID_CHAIN',
+ `Invalid chainId for a share token: ${chainId}`
+ );
+ }
+ if (invoiceId === null || invoiceId === undefined || invoiceId === '') {
+ throw new InvoiceShareError(
+ 'INVALID_INVOICE_ID',
+ 'Invalid invoiceId for a share token'
+ );
+ }
+ if (!invoiceData || typeof invoiceData !== 'object') {
+ throw new InvoiceShareError(
+ 'INVALID_PAYLOAD',
+ 'Invoice data is required to build a share token'
+ );
+ }
+
+ const envelope = {
+ v: ENVELOPE_VERSION,
+ t: ENVELOPE_TYPE,
+ id: invoiceId.toString(),
+ c: numericChainId,
+ d: invoiceData,
+ };
+
+ // Raw DEFLATE, not zlib: the two-byte zlib header buys nothing here and the
+ // token is length-critical. fflate's deflateSync is already raw.
+ const compressed = deflateSync(new TextEncoder().encode(toJson(envelope)), {
+ level: 9,
+ });
+ return TOKEN_PREFIX + bytesToBase64Url(compressed);
+}
+
+/**
+ * Unpack a share token.
+ *
+ * Every failure mode of a link that travelled through a chat app — truncated,
+ * line-wrapped, prefix stripped, produced by a newer version of the app —
+ * lands here as an InvoiceShareError rather than a decode that half-succeeds.
+ *
+ * Nothing returned is trusted. The payload still has to be checked against
+ * the on-chain hash before it is shown as an invoice or stored.
+ *
+ * @param {string} token
+ * @returns {{invoiceId: string, chainId: number, invoiceData: Object}}
+ */
+export function decodeInvoiceShare(token) {
+ if (typeof token !== 'string' || !token.trim()) {
+ throw new InvoiceShareError('EMPTY', 'No invoice share code found');
+ }
+
+ const trimmed = token.trim();
+ if (!trimmed.startsWith(TOKEN_PREFIX)) {
+ throw new InvoiceShareError(
+ 'UNKNOWN_FORMAT',
+ 'This does not look like a Chainvoice share link. It may have been ' +
+ 'shortened or truncated on the way here — ask the sender to resend it.'
+ );
+ }
+
+ let json;
+ try {
+ const bytes = base64UrlToBytes(trimmed.slice(TOKEN_PREFIX.length));
+ json = new TextDecoder().decode(inflateSync(bytes));
+ } catch {
+ throw new InvoiceShareError(
+ 'CORRUPT',
+ 'This share link is incomplete or damaged. Chat apps sometimes cut ' +
+ 'long links — ask the sender to share the invoice file instead.'
+ );
+ }
+
+ let envelope;
+ try {
+ envelope = JSON.parse(json);
+ } catch {
+ throw new InvoiceShareError('CORRUPT', 'This share link is damaged');
+ }
+
+ if (envelope?.t !== ENVELOPE_TYPE) {
+ throw new InvoiceShareError('UNKNOWN_FORMAT', 'This is not an invoice share link');
+ }
+ if (Number(envelope.v) !== ENVELOPE_VERSION) {
+ throw new InvoiceShareError(
+ 'UNSUPPORTED_VERSION',
+ `This link was made by a newer version of Chainvoice (format v${envelope.v}). Please refresh and try again.`
+ );
+ }
+
+ const chainId = Number(envelope.c);
+ if (!Number.isFinite(chainId) || chainId <= 0) {
+ throw new InvoiceShareError('INVALID_CHAIN', 'This share link has no valid network');
+ }
+ if (!envelope.id) {
+ throw new InvoiceShareError('INVALID_INVOICE_ID', 'This share link has no invoice id');
+ }
+ if (!envelope.d || typeof envelope.d !== 'object' || Array.isArray(envelope.d)) {
+ throw new InvoiceShareError('INVALID_PAYLOAD', 'This share link has no invoice data');
+ }
+
+ return {
+ invoiceId: String(envelope.id),
+ chainId,
+ invoiceData: envelope.d,
+ };
+}
+
+/**
+ * Measure a finished share URL against the practical limits.
+ *
+ * Returned rather than enforced: an invoice too big for a QR code is still
+ * perfectly shareable as a link, and one too big for a link is still
+ * shareable as a file. Only the UI knows which of those it is offering.
+ *
+ * @param {string} url
+ * @returns {{chars: number, fitsUrl: boolean, fitsQr: boolean}}
+ */
+export function describeShareSize(url) {
+ const chars = typeof url === 'string' ? url.length : 0;
+ return {
+ chars,
+ fitsUrl: chars > 0 && chars <= SHARE_URL_MAX_CHARS,
+ fitsQr: chars > 0 && chars <= SHARE_QR_MAX_CHARS,
+ };
+}
+
+export {
+ TOKEN_PREFIX,
+ ENVELOPE_VERSION,
+ SHARE_URL_MAX_CHARS,
+ SHARE_QR_MAX_CHARS,
+};
diff --git a/frontend/src/services/share/invoiceShareFile.js b/frontend/src/services/share/invoiceShareFile.js
new file mode 100644
index 00000000..f9f642f8
--- /dev/null
+++ b/frontend/src/services/share/invoiceShareFile.js
@@ -0,0 +1,84 @@
+import { encodeInvoiceShare } from './invoiceShareCodec.js';
+import { buildInvoiceShareUrl } from './invoiceShareLink.js';
+
+/**
+ * File fallback for invoices too large to share as a link or a QR code.
+ *
+ * The token itself has no size limit — links and QR codes do. A long invoice
+ * (dozens of line items) still encodes fine, it just cannot survive a chat
+ * client that truncates URLs. Writing the same token to a file sidesteps
+ * every one of those limits and keeps the share channel the sender's choice:
+ * email, Discord, a USB stick.
+ *
+ * The file is JSON with the token inside rather than the bare token, so it is
+ * inspectable — someone who opens it can see which invoice and which network
+ * it is for without running it through the app.
+ */
+
+const FILE_FORMAT = 'chainvoice-invoice-share';
+const FILE_VERSION = 1;
+const FILE_EXTENSION = 'cvinv';
+
+/**
+ * Build the object written to a `.cvinv` file.
+ *
+ * `link` is included alongside `token` purely as a convenience: it is the
+ * same data, and a recipient who received the file can paste that line
+ * instead of hunting for the import page.
+ *
+ * @param {Object} params - same shape as {@link encodeInvoiceShare}
+ * @returns {Object}
+ */
+export function buildInvoiceShareFile(params) {
+ const token = encodeInvoiceShare(params);
+ return {
+ format: FILE_FORMAT,
+ version: FILE_VERSION,
+ invoiceId: String(params.invoiceId),
+ chainId: Number(params.chainId),
+ exportedAt: new Date().toISOString(),
+ token,
+ link: buildInvoiceShareUrl(params),
+ };
+}
+
+/**
+ * Download an invoice as a `.cvinv` file.
+ *
+ * @param {Object} params - same shape as {@link encodeInvoiceShare}
+ * @returns {string} the filename written
+ */
+export function downloadInvoiceShareFile(params) {
+ const contents = buildInvoiceShareFile(params);
+ const blob = new Blob([JSON.stringify(contents, null, 2)], {
+ type: 'application/json;charset=utf-8;',
+ });
+ const url = URL.createObjectURL(blob);
+ const filename = `chainvoice-invoice-${contents.invoiceId}.${FILE_EXTENSION}`;
+
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ return filename;
+}
+
+/**
+ * Read a shared invoice file back to text.
+ *
+ * Returns the raw text rather than a parsed token so the caller can hand it
+ * to `parseInvoiceShareInput`, which already copes with a file that contains
+ * a token, a link, or the JSON wrapper around either.
+ *
+ * @param {File} file
+ * @returns {Promise}
+ */
+export function readInvoiceShareFile(file) {
+ return file.text();
+}
+
+export { FILE_FORMAT, FILE_VERSION, FILE_EXTENSION };
diff --git a/frontend/src/services/share/invoiceShareLink.js b/frontend/src/services/share/invoiceShareLink.js
new file mode 100644
index 00000000..678c8ee0
--- /dev/null
+++ b/frontend/src/services/share/invoiceShareLink.js
@@ -0,0 +1,129 @@
+import {
+ encodeInvoiceShare,
+ decodeInvoiceShare,
+ InvoiceShareError,
+ TOKEN_PREFIX,
+} from './invoiceShareCodec.js';
+
+/**
+ * Share links and the several shapes they come back in.
+ *
+ * A token leaves the app inside a URL, but it reaches the importer however
+ * the two people happened to share it: a tapped link, a pasted link, a
+ * scanned QR, a link that a chat app wrapped across two lines, or a `.cvinv`
+ * file when the invoice was too big for a URL. All of those funnel through
+ * {@link parseInvoiceShareInput} so the import page has one code path.
+ */
+
+/** Route that renders the importer. Registered in App.jsx. */
+export const SHARE_IMPORT_PATH = '/dashboard/import';
+
+/**
+ * Query parameter carrying the token.
+ *
+ * Single-letter on purpose — the whole link is length-critical, and this one
+ * is never typed by a human.
+ */
+export const SHARE_TOKEN_PARAM = 'i';
+
+/**
+ * Resolve the app's own base URL.
+ *
+ * Falls back to a bare path when there is no `window`, so building a link in
+ * a test or a non-browser context yields something inspectable rather than
+ * throwing.
+ */
+function defaultOrigin() {
+ if (typeof window === 'undefined' || !window.location) return '';
+ return window.location.origin;
+}
+
+function defaultBasePath() {
+ const base =
+ typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL
+ ? import.meta.env.BASE_URL
+ : '/';
+ return base.endsWith('/') ? base : `${base}/`;
+}
+
+/**
+ * Build the shareable URL for an invoice.
+ *
+ * The token goes in the hash fragment, never in a query string on the path.
+ * Fragments are not sent to servers, so the invoice details never appear in
+ * an access log, a CDN cache or a Referer header. The app's HashRouter puts
+ * the route there anyway, so this costs nothing.
+ *
+ * @param {Object} params - same shape as {@link encodeInvoiceShare}
+ * @param {Object} [options]
+ * @param {string} [options.origin] - override the origin (tests, previews)
+ * @returns {string} absolute share URL
+ */
+export function buildInvoiceShareUrl(params, { origin } = {}) {
+ const token = encodeInvoiceShare(params);
+ const query = new URLSearchParams({ [SHARE_TOKEN_PARAM]: token }).toString();
+ const root = origin === undefined ? defaultOrigin() : origin;
+ return `${root}${defaultBasePath()}#${SHARE_IMPORT_PATH}?${query}`;
+}
+
+/**
+ * Pull a token out of whatever the user gave us.
+ *
+ * Tolerant by design, because every one of these has been seen in the wild:
+ * whitespace and newlines injected by a chat client wrapping a long link, a
+ * link pasted without its scheme, a bare token, or the contents of an
+ * exported file. What it will not do is guess — anything with no recognisable
+ * token throws, rather than being passed downstream to fail as a corrupt
+ * payload.
+ *
+ * @param {string} input - a URL, a bare token, or exported file contents
+ * @returns {string} the token
+ */
+export function parseInvoiceShareInput(input) {
+ if (typeof input !== 'string' || !input.trim()) {
+ throw new InvoiceShareError('EMPTY', 'Paste a share link or choose a file');
+ }
+
+ // Chat clients and email wrap long links. A base64url token contains no
+ // whitespace, so stripping all of it can only repair the damage.
+ const cleaned = input.replace(/\s+/g, '');
+
+ if (cleaned.startsWith(TOKEN_PREFIX)) return cleaned;
+
+ // An exported file: JSON with the token inside. Parsed before the URL
+ // branch because file contents can also contain a full link.
+ if (cleaned.startsWith('{')) {
+ try {
+ const parsed = JSON.parse(cleaned);
+ const fromFile = parsed?.token ?? parsed?.link;
+ if (typeof fromFile === 'string') return parseInvoiceShareInput(fromFile);
+ } catch {
+ // Not JSON after all; fall through to the URL branch.
+ }
+ }
+
+ // A link. Matched with a regex rather than `new URL()` because the token
+ // lives inside the hash fragment, which URL exposes as one opaque string
+ // that still has to be picked apart — and because this has to cope with
+ // input that is not a well-formed URL at all.
+ const match = cleaned.match(
+ new RegExp(`[?&]${SHARE_TOKEN_PARAM}=(${TOKEN_PREFIX.replace('.', '\\.')}[A-Za-z0-9_-]+)`)
+ );
+ if (match) return match[1];
+
+ throw new InvoiceShareError(
+ 'UNKNOWN_FORMAT',
+ 'No invoice share code found in that link. Copy the whole link, ' +
+ 'including the part after the # sign.'
+ );
+}
+
+/**
+ * Parse and decode in one step.
+ *
+ * @param {string} input - a URL, a bare token, or exported file contents
+ * @returns {{invoiceId: string, chainId: number, invoiceData: Object}}
+ */
+export function decodeInvoiceShareInput(input) {
+ return decodeInvoiceShare(parseInvoiceShareInput(input));
+}
diff --git a/frontend/src/services/share/invoiceShareMatch.js b/frontend/src/services/share/invoiceShareMatch.js
new file mode 100644
index 00000000..36f3655a
--- /dev/null
+++ b/frontend/src/services/share/invoiceShareMatch.js
@@ -0,0 +1,49 @@
+import { verifyInvoiceHash } from '../relay/invoiceHashUtils.js';
+
+/**
+ * The decision at the heart of importing a shared invoice.
+ *
+ * A share token is public and anyone can edit one, so nothing in it may be
+ * believed on its own. What makes it trustworthy is the commitment the sender
+ * made when they created the invoice: `invoiceDataHash` on-chain. Recomputing
+ * that hash over the payload and comparing proves it is the payload the
+ * sender committed to — the same check the relay path makes in
+ * `ReceivedInvoice`'s `storeRelayInvoice`.
+ *
+ * Kept apart from the module that talks to an RPC, and free of any chain
+ * config, so the comparison everything else trusts can be exercised directly.
+ */
+
+/** Result codes, so the UI can word each outcome for itself. */
+export const VERIFY_OK = 'ok';
+export const VERIFY_UNSUPPORTED_CHAIN = 'unsupported_chain';
+export const VERIFY_NOT_FOUND = 'not_found';
+export const VERIFY_HASH_MISMATCH = 'hash_mismatch';
+export const VERIFY_UNREACHABLE = 'unreachable';
+
+/**
+ * Decide whether a payload matches an invoice as the contract returned it.
+ *
+ * @param {Array} raw - the `InvoiceDetails` tuple from `getInvoice`
+ * @param {Object} invoiceData - payload from the share token
+ * @returns {{code: string, onChain: Object}}
+ */
+export function evaluateOnChainInvoice(raw, invoiceData) {
+ const onChain = {
+ invoiceId: raw[0].toString(),
+ from: raw[1].toLowerCase(),
+ to: raw[2].toLowerCase(),
+ amountDue: raw[3],
+ tokenAddress: raw[4],
+ isPaid: raw[5],
+ isCancelled: raw[6],
+ invoiceDataHash: raw[7],
+ };
+
+ return {
+ code: verifyInvoiceHash(invoiceData, onChain.invoiceDataHash)
+ ? VERIFY_OK
+ : VERIFY_HASH_MISMATCH,
+ onChain,
+ };
+}
diff --git a/frontend/src/services/share/invoiceShareVerify.js b/frontend/src/services/share/invoiceShareVerify.js
new file mode 100644
index 00000000..42591c7d
--- /dev/null
+++ b/frontend/src/services/share/invoiceShareVerify.js
@@ -0,0 +1,115 @@
+import { ethers } from 'ethers';
+import { chainConfig } from '../../utils/chainConfig.js';
+import { ChainvoiceABI } from '../../contractsABI/ChainvoiceABI.js';
+import {
+ evaluateOnChainInvoice,
+ VERIFY_NOT_FOUND,
+ VERIFY_UNREACHABLE,
+ VERIFY_UNSUPPORTED_CHAIN,
+} from './invoiceShareMatch.js';
+
+/**
+ * Check a shared invoice against the chain, without a wallet.
+ *
+ * A share token is public and anyone can edit one, so nothing in it may be
+ * believed on its own. What makes it trustworthy is the same commitment the
+ * relay path relies on: the sender hashed the payload into `invoiceDataHash`
+ * when they created the invoice, so recomputing that hash and comparing it
+ * against the chain proves the payload is the one the sender committed to.
+ * See `evaluateOnChainInvoice`, which makes that comparison.
+ *
+ * Deliberately read-only and wallet-free. Every supported chain ships a
+ * public RPC URL in its wagmi config, so an invoice can be decoded, verified
+ * and rendered before the recipient connects anything — which is the point of
+ * a shared link. Connecting is only needed to save or pay it.
+ */
+
+/**
+ * Find the wagmi chain definition for a chain id.
+ * @param {number|string} chainId
+ * @returns {Object|undefined}
+ */
+function findChain(chainId) {
+ return chainConfig.find((chain) => Number(chain.id) === Number(chainId));
+}
+
+/**
+ * Build a read-only provider for a chain from its public RPC URL.
+ *
+ * `staticNetwork` matters: without it ethers issues an `eth_chainId` probe
+ * per provider and retries network detection on failure, which turns an
+ * unreachable RPC into a slow hang instead of a prompt error.
+ *
+ * @param {number|string} chainId
+ * @returns {ethers.JsonRpcProvider|null}
+ */
+export function getPublicProvider(chainId) {
+ const chain = findChain(chainId);
+ const url = chain?.rpcUrls?.default?.http?.[0];
+ if (!url) return null;
+ return new ethers.JsonRpcProvider(url, Number(chain.id), {
+ staticNetwork: true,
+ });
+}
+
+/**
+ * Read the Chainvoice contract address configured for a chain.
+ *
+ * Mirrors how every other caller resolves it. An empty value means the chain
+ * is deliberately not wired up yet — see the notes in `.env.example` about
+ * deployments still running the v1 contract.
+ *
+ * @param {number|string} chainId
+ * @returns {string|null}
+ */
+export function getContractAddress(chainId) {
+ const configured = import.meta.env[`VITE_CONTRACT_ADDRESS_${Number(chainId)}`];
+ return configured ? configured : null;
+}
+
+/**
+ * Verify a decoded share token against its on-chain commitment.
+ *
+ * Never throws for an untrustworthy invoice — an unverifiable payload is an
+ * expected outcome of opening a stranger's link, not an exception. Callers
+ * switch on `code` and show the invoice only when it is `ok`.
+ *
+ * @param {Object} params
+ * @param {string} params.invoiceId
+ * @param {number|string} params.chainId
+ * @param {Object} params.invoiceData - payload from the token
+ * @returns {Promise<{code: string, onChain?: Object, error?: Error}>}
+ */
+export async function verifyShareAgainstChain({ invoiceId, chainId, invoiceData }) {
+ const chain = findChain(chainId);
+ const contractAddress = getContractAddress(chainId);
+ if (!chain || !contractAddress) {
+ return { code: VERIFY_UNSUPPORTED_CHAIN, chainName: chain?.name };
+ }
+
+ const provider = getPublicProvider(chainId);
+ if (!provider) {
+ return { code: VERIFY_UNSUPPORTED_CHAIN, chainName: chain.name };
+ }
+
+ let raw;
+ try {
+ const contract = new ethers.Contract(contractAddress, ChainvoiceABI, provider);
+ raw = await contract.getInvoice(invoiceId);
+ } catch (err) {
+ // A revert means the id does not exist on this contract; anything else is
+ // the RPC being unreachable. The two need different wording — one is a bad
+ // link, the other is "try again in a minute".
+ const isRevert =
+ err?.code === 'CALL_EXCEPTION' || err?.code === 'BAD_DATA';
+ return {
+ code: isRevert ? VERIFY_NOT_FOUND : VERIFY_UNREACHABLE,
+ chainName: chain.name,
+ error: err,
+ };
+ } finally {
+ provider.destroy?.();
+ }
+
+ return { ...evaluateOnChainInvoice(raw, invoiceData), chainName: chain.name };
+}
diff --git a/frontend/src/utils/layout.js b/frontend/src/utils/layout.js
new file mode 100644
index 00000000..d99f6d22
--- /dev/null
+++ b/frontend/src/utils/layout.js
@@ -0,0 +1,31 @@
+/**
+ * Shared spacing for the app shell.
+ *
+ * One gutter for the navbar and every page, so the logo, the nav links and
+ * the page content all line up on the same left and right edges. Pages add no
+ * max-width of their own — an inner `max-w-*` on top of the dashboard sidebar
+ * left a large dead gutter on one side only.
+ */
+
+/**
+ * Gutter shared by the navbar and all page shells.
+ *
+ * No max-width: a cap wide enough to matter still left a visible band of dead
+ * space down both sides. What made the layout read as stretched was blocks not
+ * sharing a right edge, not the page being wide — so the blocks align instead
+ * and the shell simply uses the screen.
+ */
+export const SHELL = "w-full px-3 sm:px-4 lg:px-6";
+
+/** Outer wrapper for a page rendered inside the dashboard outlet. */
+export const PAGE_CONTAINER = "w-full px-0 sm:px-1";
+
+/** Vertical gap between the major sections of a page. */
+export const SECTION_GAP = "mb-4 sm:mb-5";
+
+/** Standard white content card. */
+export const CARD =
+ "bg-white rounded-lg border border-gray-200 shadow-sm p-3 sm:p-4 overflow-hidden";
+
+/** Page title block: heading plus optional supporting line. */
+export const PAGE_HEADER = "mb-3 sm:mb-4";
diff --git a/frontend/tests/services/invoiceShareCodec.test.js b/frontend/tests/services/invoiceShareCodec.test.js
new file mode 100644
index 00000000..9abaa5ad
--- /dev/null
+++ b/frontend/tests/services/invoiceShareCodec.test.js
@@ -0,0 +1,285 @@
+import {
+ encodeInvoiceShare,
+ decodeInvoiceShare,
+ describeShareSize,
+ InvoiceShareError,
+ TOKEN_PREFIX,
+ SHARE_URL_MAX_CHARS,
+ SHARE_QR_MAX_CHARS,
+} from "../../src/services/share/invoiceShareCodec.js";
+import {
+ buildInvoiceShareUrl,
+ parseInvoiceShareInput,
+ decodeInvoiceShareInput,
+} from "../../src/services/share/invoiceShareLink.js";
+import { buildInvoiceShareFile } from "../../src/services/share/invoiceShareFile.js";
+import {
+ evaluateOnChainInvoice,
+ VERIFY_OK,
+ VERIFY_HASH_MISMATCH,
+} from "../../src/services/share/invoiceShareMatch.js";
+import { deflateSync } from "fflate";
+import { bytesToBase64Url } from "../../src/services/relay/invoiceCrypto.js";
+import {
+ computeInvoiceHash,
+ verifyInvoiceHash,
+} from "../../src/services/relay/invoiceHashUtils.js";
+
+const ORIGIN = "https://chainvoice.example";
+
+/** A payload shaped exactly like the one CreateInvoice hashes. */
+function makeInvoiceData(itemCount = 3) {
+ return {
+ amountDue: "1234.567891",
+ dueDate: "2026-09-30",
+ issueDate: "2026-08-31",
+ paymentToken: {
+ address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ symbol: "USDC",
+ decimals: 6,
+ },
+ user: {
+ address: "0x1111111111111111111111111111111111111111",
+ fname: "Bob",
+ lname: "Builder",
+ email: "bob@example.com",
+ country: "United States",
+ city: "San Francisco",
+ postalcode: "94103",
+ },
+ client: {
+ address: "0x2222222222222222222222222222222222222222",
+ fname: "Alice",
+ lname: "Anders",
+ email: "alice@example.com",
+ country: "Germany",
+ city: "Berlin",
+ postalcode: "10115",
+ },
+ items: Array.from({ length: itemCount }, (_, i) => ({
+ name: `Line item ${i + 1}`,
+ qty: String(i + 1),
+ unitPrice: "120.50",
+ amount: "361.50",
+ })),
+ };
+}
+
+const share = { invoiceId: "42", chainId: 11155111, invoiceData: makeInvoiceData() };
+
+describe("encodeInvoiceShare / decodeInvoiceShare", () => {
+ it("round-trips an invoice payload", () => {
+ const decoded = decodeInvoiceShare(encodeInvoiceShare(share));
+ expect(decoded.invoiceId).toBe("42");
+ expect(decoded.chainId).toBe(11155111);
+ expect(decoded.invoiceData).toEqual(share.invoiceData);
+ });
+
+ it("preserves the on-chain hash across the round trip", () => {
+ // The whole trust model rests on this: the importer recomputes the hash
+ // over what came out of the token and compares it to the chain, so the
+ // payload has to survive encoding byte-identically as far as
+ // stableStringify is concerned.
+ const hash = computeInvoiceHash(share.invoiceData);
+ const decoded = decodeInvoiceShare(encodeInvoiceShare(share));
+ expect(verifyInvoiceHash(decoded.invoiceData, hash)).toBe(true);
+ });
+
+ it("emits a versioned token", () => {
+ expect(encodeInvoiceShare(share).startsWith(TOKEN_PREFIX)).toBe(true);
+ });
+
+ it("accepts a bigint invoice id, as read from a contract", () => {
+ const decoded = decodeInvoiceShare(
+ encodeInvoiceShare({ ...share, invoiceId: 42n })
+ );
+ expect(decoded.invoiceId).toBe("42");
+ });
+
+ it("stringifies bigints inside the payload", () => {
+ const decoded = decodeInvoiceShare(
+ encodeInvoiceShare({
+ ...share,
+ invoiceData: { ...share.invoiceData, amountDue: 1234n },
+ })
+ );
+ expect(decoded.invoiceData.amountDue).toBe("1234");
+ });
+
+ it("compresses well below the link budget for a typical invoice", () => {
+ const url = buildInvoiceShareUrl(share, { origin: ORIGIN });
+ expect(describeShareSize(url).fitsQr).toBe(true);
+ });
+
+ it("rejects a missing chain id", () => {
+ expect(() => encodeInvoiceShare({ ...share, chainId: 0 })).toThrow(
+ InvoiceShareError
+ );
+ });
+
+ it("rejects a missing invoice id", () => {
+ expect(() => encodeInvoiceShare({ ...share, invoiceId: "" })).toThrow(
+ InvoiceShareError
+ );
+ });
+
+ it("rejects a missing payload", () => {
+ expect(() => encodeInvoiceShare({ ...share, invoiceData: null })).toThrow(
+ InvoiceShareError
+ );
+ });
+});
+
+describe("decodeInvoiceShare failure modes", () => {
+ const expectCode = (fn, code) => {
+ try {
+ fn();
+ throw new Error("expected a throw");
+ } catch (err) {
+ expect(err).toBeInstanceOf(InvoiceShareError);
+ expect(err.code).toBe(code);
+ }
+ };
+
+ it("rejects an empty token", () => {
+ expectCode(() => decodeInvoiceShare(" "), "EMPTY");
+ });
+
+ it("rejects a token with no recognised prefix", () => {
+ expectCode(() => decodeInvoiceShare("https://example.com/x"), "UNKNOWN_FORMAT");
+ });
+
+ it("rejects a truncated token", () => {
+ const token = encodeInvoiceShare(share);
+ expectCode(() => decodeInvoiceShare(token.slice(0, token.length - 12)), "CORRUPT");
+ });
+
+ it("rejects a token whose payload has been edited", () => {
+ // Flipping characters inside the base64url body breaks the DEFLATE stream
+ // or the JSON inside it. The on-chain hash check is the real defence, but
+ // a mangled token should fail here first with a clearer message.
+ const token = encodeInvoiceShare(share);
+ const mid = Math.floor(token.length / 2);
+ const swapped =
+ token.slice(0, mid) +
+ (token[mid] === "A" ? "B" : "A") +
+ token.slice(mid + 1);
+ expect(() => decodeInvoiceShare(swapped)).toThrow(InvoiceShareError);
+ });
+
+ it("rejects a future format version", () => {
+ // Built by hand rather than by bumping the constant: this asserts what an
+ // older build does when handed a token from a newer one.
+ const envelope = JSON.stringify({ v: 99, t: "invoice", id: "1", c: 1, d: {} });
+ const token =
+ TOKEN_PREFIX +
+ bytesToBase64Url(deflateSync(new TextEncoder().encode(envelope)));
+ expectCode(() => decodeInvoiceShare(token), "UNSUPPORTED_VERSION");
+ });
+});
+
+describe("buildInvoiceShareUrl / parseInvoiceShareInput", () => {
+ it("puts the token in the hash fragment, never on the path", () => {
+ const url = buildInvoiceShareUrl(share, { origin: ORIGIN });
+ const [beforeHash, afterHash] = url.split("#");
+ expect(beforeHash).toBe(`${ORIGIN}/`);
+ expect(afterHash).toContain("/dashboard/import?i=cv1.");
+ });
+
+ it("round-trips through a full URL", () => {
+ const url = buildInvoiceShareUrl(share, { origin: ORIGIN });
+ expect(decodeInvoiceShareInput(url).invoiceData).toEqual(share.invoiceData);
+ });
+
+ it("recovers a link that a chat client wrapped across lines", () => {
+ const url = buildInvoiceShareUrl(share, { origin: ORIGIN });
+ const mid = Math.floor(url.length / 2);
+ const wrapped = `${url.slice(0, mid)}\n ${url.slice(mid)}`;
+ expect(decodeInvoiceShareInput(wrapped).invoiceId).toBe("42");
+ });
+
+ it("accepts a bare token", () => {
+ expect(parseInvoiceShareInput(encodeInvoiceShare(share))).toBe(
+ encodeInvoiceShare(share)
+ );
+ });
+
+ it("accepts exported file contents", () => {
+ const contents = JSON.stringify(
+ buildInvoiceShareFile({ ...share, chainId: 11155111 })
+ );
+ expect(decodeInvoiceShareInput(contents).invoiceId).toBe("42");
+ });
+
+ it("rejects a link with no token in it", () => {
+ expect(() => parseInvoiceShareInput(`${ORIGIN}/#/dashboard/import`)).toThrow(
+ InvoiceShareError
+ );
+ });
+});
+
+describe("describeShareSize", () => {
+ it("flags an invoice that fits a link but not a QR code", () => {
+ const size = describeShareSize("x".repeat(SHARE_QR_MAX_CHARS + 1));
+ expect(size.fitsUrl).toBe(true);
+ expect(size.fitsQr).toBe(false);
+ });
+
+ it("flags an invoice too large for a link", () => {
+ const size = describeShareSize("x".repeat(SHARE_URL_MAX_CHARS + 1));
+ expect(size.fitsUrl).toBe(false);
+ expect(size.fitsQr).toBe(false);
+ });
+
+ it("treats an empty url as fitting nothing", () => {
+ expect(describeShareSize("")).toEqual({
+ chars: 0,
+ fitsUrl: false,
+ fitsQr: false,
+ });
+ });
+});
+
+describe("evaluateOnChainInvoice", () => {
+ /** An `InvoiceDetails` tuple shaped as ethers returns it from getInvoice. */
+ const tuple = (hash) => [
+ 42n,
+ "0x1111111111111111111111111111111111111111",
+ "0x2222222222222222222222222222222222222222",
+ 10000000n,
+ "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ false,
+ false,
+ hash,
+ ];
+
+ it("accepts a payload that survived the share round trip", () => {
+ // The branch the browser cannot reach without a funded on-chain invoice:
+ // the token decodes, and its hash matches what the sender committed.
+ const decoded = decodeInvoiceShare(encodeInvoiceShare(share));
+ const result = evaluateOnChainInvoice(
+ tuple(computeInvoiceHash(share.invoiceData)),
+ decoded.invoiceData
+ );
+ expect(result.code).toBe(VERIFY_OK);
+ expect(result.onChain.to).toBe("0x2222222222222222222222222222222222222222");
+ expect(result.onChain.invoiceId).toBe("42");
+ });
+
+ it("rejects a payload edited after the sender committed it", () => {
+ const tampered = {
+ ...share.invoiceData,
+ amountDue: "999999.00",
+ };
+ const result = evaluateOnChainInvoice(
+ tuple(computeInvoiceHash(share.invoiceData)),
+ tampered
+ );
+ expect(result.code).toBe(VERIFY_HASH_MISMATCH);
+ });
+
+ it("rejects an invoice with no commitment recorded", () => {
+ const result = evaluateOnChainInvoice(tuple(""), share.invoiceData);
+ expect(result.code).toBe(VERIFY_HASH_MISMATCH);
+ });
+});