diff --git a/README.md b/README.md index ed16f6ae..03085fe7 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,21 @@ VITE_WALLETCONNECT_PROJECT_ID=Your Project ID can be obtained from https://dashb ``` > ⚠️ **Security Note:** Never commit `.env` files to version control. Keep your private keys secure. +### Relay configuration + +Invoice payloads travel encrypted over a [ThruBox](https://github.com/AOSSIE-Org/ThruBox-Server) relay, configured with: + +```env +VITE_RELAY_URL=http://localhost:3000 +VITE_RELAY_API_KEY= +VITE_RELAY_TIMEOUT_MS= +``` + +- **`VITE_RELAY_URL`** — in development this is the target the Vite dev server proxies `/relay` to, so the browser stays same-origin. In production, either an absolute `https://` URL (which requires CORS on the relay) or a path such as `/relay` that your host rewrites to it (Vercel rewrites, Netlify redirects, nginx `proxy_pass`), which avoids CORS entirely. +- **`VITE_RELAY_API_KEY`** — only needed if the relay sets `security.api_key`. **This is not a secret:** Vite inlines every `VITE_`-prefixed variable into the built JavaScript, so any visitor can read it. Treat it as a spam speed-bump, not access control. To keep a relay key private, proxy relay calls server-side and inject it there. +- **`VITE_RELAY_TIMEOUT_MS`** — request timeout, default `15000`. Raise it (around `60000`) on hosts that suspend idle instances: a cold start can take most of a minute, and sends are deliberately not retried, so a timeout means an undelivered invoice. + + ## Deployed Contracts ### v1 (Mainnet Deployment — Jan 1) - Ethereum Sepolia (11155111) diff --git a/frontend/.env.example b/frontend/.env.example index 4c220784..ec422c61 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -10,3 +10,26 @@ VITE_CONTRACT_ADDRESS_61=0xD044A85a5daC307217B9bF313A90E8a60AF7DdCe VITE_CONTRACT_ADDRESS_137=0xD044A85a5daC307217B9bF313A90E8a60AF7DdCe VITE_WALLETCONNECT_PROJECT_ID= + +# ThruBox relay — transport for encrypted invoice payloads. +# In development this is the target the Vite dev server proxies /relay to. +# +# In production, either: +# - an absolute URL (https://relay.example.com) — needs CORS on the relay, or +# - a path (/relay) — the host rewrites it to the relay, so the browser stays +# same-origin and no CORS is needed. Works with Vercel rewrites, Netlify +# redirects, or an nginx proxy_pass. +VITE_RELAY_URL=http://localhost:3000 + +# Only needed if the relay is configured with security.api_key. Leave blank otherwise. +# +# NOT A SECRET. Vite inlines every VITE_-prefixed variable into the built +# JavaScript, so anyone can read this out of the bundle. Treat it as a spam +# speed-bump, not access control — it cannot restrict the relay to this app. +# To keep a relay key private, proxy relay calls server-side (see VITE_RELAY_URL +# above) and inject the key there instead of shipping it to the browser. +VITE_RELAY_API_KEY= + +# Request timeout in ms (default 15000). Raise it on hosts that suspend idle +# instances — a cold start can take ~60s, and sends are not retried. +VITE_RELAY_TIMEOUT_MS= diff --git a/frontend/README.md b/frontend/README.md index 47c867f9..76ec08bb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -61,6 +61,20 @@ VITE_CONTRACT_ADDRESS_137=0xD044A85a5daC307217B9bF313A90E8a60AF7DdCe VITE_WALLETCONNECT_PROJECT_ID= ``` +### Relay configuration + +Invoice payloads travel encrypted over a [ThruBox](https://github.com/AOSSIE-Org/ThruBox-Server) relay, configured with: + +```env +VITE_RELAY_URL=http://localhost:3000 +VITE_RELAY_API_KEY= +VITE_RELAY_TIMEOUT_MS= +``` + +- **`VITE_RELAY_URL`** — in development this is the target the Vite dev server proxies `/relay` to, so the browser stays same-origin. In production, either an absolute `https://` URL (which requires CORS on the relay) or a path such as `/relay` that your host rewrites to it (Vercel rewrites, Netlify redirects, nginx `proxy_pass`), which avoids CORS entirely. +- **`VITE_RELAY_API_KEY`** — only needed if the relay sets `security.api_key`. **This is not a secret:** Vite inlines every `VITE_`-prefixed variable into the built JavaScript, so any visitor can read it. Treat it as a spam speed-bump, not access control. To keep a relay key private, proxy relay calls server-side and inject it there. +- **`VITE_RELAY_TIMEOUT_MS`** — request timeout, default `15000`. Raise it (around `60000`) on hosts that suspend idle instances: a cold start can take most of a minute, and sends are deliberately not retried, so a timeout means an undelivered invoice. + To enable Web3 wallet functionality, create a free WalletConnect Project ID from the Reown dashboard: ```text diff --git a/frontend/jest.config.cjs b/frontend/jest.config.cjs index 1f879153..e71e6e36 100644 --- a/frontend/jest.config.cjs +++ b/frontend/jest.config.cjs @@ -5,6 +5,9 @@ module.exports = { collectCoverageFrom: [ "src/utils/invoiceCalculations.js", "src/utils/invoiceValidation.js", + "src/services/relay/invoiceCrypto.js", + "src/services/relay/invoiceHashUtils.js", + "src/services/relay/relayInvoiceMessaging.js", ], coverageDirectory: "/coverage", }; diff --git a/frontend/package.json b/frontend/package.json index 71077e0f..fe80757a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@aossie-org/idb-backup": "^1.0.0", + "@aossie-org/thrubox-client": "1.0.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.4.6", @@ -31,6 +32,7 @@ "clsx": "^2.1.1", "crypto-convert": "^2.1.7", "date-fns": "^3.6.0", + "eciesjs": "0.5.0", "ethers": "^6.13.5", "framer-motion": "^12.23.12", "html2canvas": "^1.4.1", diff --git a/frontend/src/hooks/useRelayKeys.js b/frontend/src/hooks/useRelayKeys.js new file mode 100644 index 00000000..0a60bde2 --- /dev/null +++ b/frontend/src/hooks/useRelayKeys.js @@ -0,0 +1,229 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useAccount, useWalletClient } from 'wagmi'; +import { BrowserProvider, Contract } from 'ethers'; +import { ChainvoiceABI } from '../contractsABI/ChainvoiceABI.js'; +import { + deriveRelayKeyPair, + registerPublicKeyOnChain, + fetchPublicKeyFromChain, + bytesToHex, + getCachedKeyPair, + clearCachedKeys, +} from '../services/relay/relayKeyManager.js'; + +/** + * React hook for managing the user's ECIES messaging keypair. + * Handles derivation from a wallet signature, session caching, + * and on-chain registration status. + */ +export function useRelayKeys() { + const { data: walletClient } = useWalletClient(); + const { address, chainId } = useAccount(); + const [keys, setKeys] = useState(null); + const [hasKeys, setHasKeys] = useState(false); + const [isRegistered, setIsRegistered] = useState(false); + const [isUnsupportedNetwork, setIsUnsupportedNetwork] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + // Persist to sessionStorage by default. Without this the private key lives + // only in module memory, so a page reload silently loses it and anything + // gated on key availability — like polling the relay for new invoices — + // never starts again. sessionStorage still dies with the tab. + const [rememberSession, setRememberSession] = useState(true); + + // Always the address of the currently connected account, readable from + // inside in-flight async work to tell whether its result is still wanted. + // Written in an effect rather than during render: a render can be discarded + // without committing (StrictMode double-renders, for one), and recording an + // address the committed tree never used would make the staleness check + // discard results that are actually current. + const activeAddressRef = useRef(address); + useEffect(() => { + activeAddressRef.current = address; + }, [address]); + + // Reset account-scoped state and republish any already-derived key in one + // pass. Reads the cache directly rather than going through + // deriveRelayKeyPair, which would fall through to signMessage when the cache + // is empty or corrupt and pop an unexplained signature prompt on page load. + useEffect(() => { + setIsRegistered(false); + setError(null); + + const cached = address ? getCachedKeyPair(address) : null; + setKeys(cached); + setHasKeys(cached !== null); + }, [address]); + + // Drop the stored key for an account the user has actually left — a + // disconnect, or a switch to a different account. + // + // Deliberately not an effect cleanup: cleanups also run on unmount, which + // would wipe the key on every route change, on any second consumer of this + // hook unmounting, and once immediately in StrictMode — throwing away the + // key the effect above had just restored, and defeating the reason for + // persisting it at all. + const previousAddressRef = useRef(address); + useEffect(() => { + const previous = previousAddressRef.current; + previousAddressRef.current = address; + if (previous && previous !== address) { + clearCachedKeys(previous); + } + }, [address]); + + const getContract = useCallback(async () => { + if (!walletClient || !chainId) return null; + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const contractAddress = import.meta.env[`VITE_CONTRACT_ADDRESS_${chainId}`]; + if (!contractAddress) return null; + return new Contract(contractAddress, ChainvoiceABI, signer); + }, [walletClient, chainId]); + + /** Derive keys from wallet signature without registering on-chain. */ + const deriveKeysOnly = useCallback( + async (remember) => { + if (!walletClient || !address) throw new Error('Wallet not connected'); + const requestedAddress = address; + const provider = new BrowserProvider(walletClient); + const signer = await provider.getSigner(); + const shouldRemember = remember !== undefined ? remember : rememberSession; + const keyPair = await deriveRelayKeyPair(signer, requestedAddress, shouldRemember); + // Signing takes as long as the user takes, so the account may have + // changed underneath us; publishing then would attach one account's key + // to another. + if (activeAddressRef.current !== requestedAddress) return keyPair; + setKeys(keyPair); + setHasKeys(true); + return keyPair; + }, + [walletClient, address, rememberSession] + ); + + /** Check if the user's public key is registered on-chain. */ + const checkRegistration = useCallback(async () => { + const requestedAddress = address; + // Reading the registry is an async chain call. If the user switches + // accounts while it is in flight, the resolved value describes the old + // account — applying it would report the previous account's registration + // state for the current one, and could skip the setup step for an address + // that has no key on chain. + const isStale = () => activeAddressRef.current !== requestedAddress; + + // "No contract on this chain" is not the same as "not registered", and + // registering cannot fix it — report it separately so callers can say so + // rather than offering a setup step that is guaranteed to fail. + const unsupported = + Boolean(chainId) && !import.meta.env[`VITE_CONTRACT_ADDRESS_${chainId}`]; + if (!isStale()) setIsUnsupportedNetwork(unsupported); + if (unsupported) { + if (!isStale()) setIsRegistered(false); + return false; + } + + const contract = await getContract(); + if (!contract || !requestedAddress) { + if (!isStale()) setIsRegistered(false); + return false; + } + try { + const pubKey = await fetchPublicKeyFromChain(contract, requestedAddress); + const registered = pubKey !== null && pubKey.length > 0; + if (!isStale()) setIsRegistered(registered); + return registered; + } catch (err) { + // A transient RPC failure is indistinguishable from "not registered" in + // the returned value, so leave a trace of which one it was. + console.warn('[useRelayKeys] Registry read failed:', err); + if (!isStale()) setIsRegistered(false); + return false; + } + }, [getContract, address, chainId]); + + /** Derive keys AND register the public key on-chain (if not already). */ + const deriveAndRegister = useCallback( + async (remember) => { + const requestedAddress = address; + // Abort rather than publish if the account changes mid-flow: the signer, + // the key being compared and the key being registered must all describe + // the same account. + const assertSameAccount = () => { + if (activeAddressRef.current !== requestedAddress) { + throw new Error('Account changed during key registration'); + } + }; + + try { + setIsLoading(true); + setError(null); + + const keyPair = await deriveKeysOnly(remember); + assertSameAccount(); + + const contract = await getContract(); + if (!contract) throw new Error('Contract not available on this network'); + assertSameAccount(); + + // Skip the transaction if the same key is already registered + const existingKey = await fetchPublicKeyFromChain(contract, requestedAddress); + assertSameAccount(); + if (existingKey && existingKey.length > 0) { + if (bytesToHex(existingKey) === bytesToHex(keyPair.publicKey)) { + setIsRegistered(true); + return; + } + } + + await registerPublicKeyOnChain(contract, keyPair.publicKey); + assertSameAccount(); + setIsRegistered(true); + } catch (err) { + console.error('[useRelayKeys] Failed to derive/register keys:', err); + // The rejection value is whatever the wallet provider threw, which is + // not guaranteed to be an Error. Reading .message off a string or null + // would throw from inside this catch, losing the real failure and + // never running setError. + const rawMessage = + typeof err?.message === 'string' ? err.message : String(err ?? ''); + const code = err?.code; + let errMsg = rawMessage || 'Failed to register messaging keys'; + if ( + errMsg.toLowerCase().includes('user rejected') || + errMsg.toLowerCase().includes('rejected the request') || + code === 'ACTION_REJECTED' || + code === 4001 + ) { + errMsg = + 'Signature request rejected. You must sign the message to enable encrypted invoices.'; + } + setError(errMsg); + throw err; + } finally { + setIsLoading(false); + } + }, + [deriveKeysOnly, getContract, address] + ); + + // Check registration status when wallet/chain changes + useEffect(() => { + if (address && walletClient && chainId) { + checkRegistration().catch(() => {}); + } + }, [address, walletClient, chainId, checkRegistration]); + + return { + keys, + hasKeys, + isRegistered, + isUnsupportedNetwork, + isLoading, + error, + rememberSession, + setRememberSession, + deriveAndRegister, + deriveKeysOnly, + checkRegistration, + }; +} diff --git a/frontend/src/services/relay/index.js b/frontend/src/services/relay/index.js new file mode 100644 index 00000000..956c2de4 --- /dev/null +++ b/frontend/src/services/relay/index.js @@ -0,0 +1,30 @@ +export { + getRelayClient, + resetRelayClient, + isRelayHealthy, + RELAY_PROXY_PATH, +} from './relayClient.js'; +export { + deriveRelayKeyPair, + registerPublicKeyOnChain, + fetchPublicKeyFromChain, + clearCachedKeys, + hasCachedKeys, + getCachedKeyPair, + hexToBytes, + bytesToHex, + DERIVATION_MESSAGE, +} from './relayKeyManager.js'; +export { + encryptPayload, + decryptPayload, + tryDecryptPayload, +} from './invoiceCrypto.js'; +export { + sendEncryptedInvoice, + fetchInvoiceMessages, + pollInvoiceMessages, + toMailboxAddress, + DEFAULT_POLL_INTERVAL_MS, +} from './relayInvoiceMessaging.js'; +export { computeInvoiceHash, verifyInvoiceHash, stableStringify } from './invoiceHashUtils.js'; diff --git a/frontend/src/services/relay/invoiceCrypto.js b/frontend/src/services/relay/invoiceCrypto.js new file mode 100644 index 00000000..6b0f9b39 --- /dev/null +++ b/frontend/src/services/relay/invoiceCrypto.js @@ -0,0 +1,88 @@ +import { encrypt, decrypt } from 'eciesjs'; + +/** + * End-to-end encryption for invoice payloads. + * + * The relay is a dumb mailbox: it stores an opaque base64 blob and never + * sees plaintext. Confidentiality comes entirely from this module. + * + * Scheme: ECIES over secp256k1 (ephemeral ECDH -> HKDF-SHA256 -> AES-256-GCM), + * the same primitive and the same 65-byte uncompressed public keys used by + * the on-chain key registry, so registered keys keep working unchanged. + */ + +/** Encode bytes as base64 without blowing the stack on large payloads. */ +function bytesToBase64(bytes) { + const CHUNK = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply( + null, + bytes.subarray(i, i + CHUNK) + ); + } + return btoa(binary); +} + +/** Decode a base64 string back into bytes. */ +function base64ToBytes(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Encrypt a JSON-serialisable value for a recipient's public key. + * + * @param {Uint8Array|string} publicKey - recipient's secp256k1 public key + * (65-byte uncompressed bytes, or 0x-prefixed hex) + * @param {*} value - any JSON-serialisable value; bigints are stringified + * @returns {string} base64 ciphertext, ready to hand to the relay + */ +export function encryptPayload(publicKey, value) { + const json = JSON.stringify(value, (_, v) => + typeof v === 'bigint' ? v.toString() : v + ); + const ciphertext = encrypt(publicKey, new TextEncoder().encode(json)); + return bytesToBase64(ciphertext); +} + +/** + * Decrypt a base64 ciphertext produced by {@link encryptPayload}. + * + * Throws if the payload was not encrypted for this key — callers polling a + * public mailbox should treat a throw as "not for me" and move on. + * + * @param {Uint8Array|string} privateKey - recipient's 32-byte private key + * @param {string} base64Ciphertext + * @returns {*} the decrypted value + */ +export function decryptPayload(privateKey, base64Ciphertext) { + const plaintext = decrypt(privateKey, base64ToBytes(base64Ciphertext)); + return JSON.parse(new TextDecoder().decode(plaintext)); +} + +/** + * Attempt decryption, returning null instead of throwing. + * + * The relay mailbox is world-readable and world-writable, so a polling + * client routinely encounters messages it cannot decrypt: junk from other + * senders, or invoices encrypted to a key the user has since rotated. + * Those are normal, not errors. + * + * @param {Uint8Array|string} privateKey + * @param {string} base64Ciphertext + * @returns {*|null} + */ +export function tryDecryptPayload(privateKey, base64Ciphertext) { + try { + return decryptPayload(privateKey, base64Ciphertext); + } catch { + return null; + } +} + +export { bytesToBase64, base64ToBytes }; diff --git a/frontend/src/services/relay/invoiceHashUtils.js b/frontend/src/services/relay/invoiceHashUtils.js new file mode 100644 index 00000000..6bb7b29f --- /dev/null +++ b/frontend/src/services/relay/invoiceHashUtils.js @@ -0,0 +1,68 @@ +import { ethers } from 'ethers'; + +/** + * Compute a deterministic keccak256 hash of invoice data. + * Keys are sorted recursively to ensure the same data always + * produces the same hash regardless of property insertion order. + * + * This is the commitment stored on-chain as `invoiceDataHash`. The + * plaintext never touches the chain — the receiver recomputes this hash + * from the payload delivered over the relay and compares it against the + * on-chain value to prove the sender did not tamper with it in transit. + * + * @param {Object} invoiceData - the invoice data object + * @returns {string} - keccak256 hash as hex string (0x-prefixed) + */ +export function computeInvoiceHash(invoiceData) { + const serialized = stableStringify(invoiceData); + return ethers.keccak256(ethers.toUtf8Bytes(serialized)); +} + +/** + * Verify that an invoice's data matches an expected hash. + * + * @param {Object} invoiceData - the invoice data object + * @param {string} expectedHash - the expected hash (from on-chain) + * @returns {boolean} + */ +export function verifyInvoiceHash(invoiceData, expectedHash) { + if (!expectedHash) return false; + const computed = computeInvoiceHash(invoiceData); + return computed.toLowerCase() === expectedHash.toLowerCase(); +} + +/** + * Deterministic JSON serialization with sorted keys (recursive). + * Ensures the same object always produces the same string + * regardless of key insertion order. + * + * @param {*} obj + * @returns {string} + */ +export function stableStringify(obj) { + // `null` for undefined, matching what JSON.stringify does to an undefined + // array entry. The payload reaches the receiver as JSON, so anything this + // renders differently to JSON.stringify makes the two sides compute + // different hashes and silently fails verification. + if (obj === null || obj === undefined) return 'null'; + if (typeof obj === 'bigint') return JSON.stringify(obj.toString()); + if (obj instanceof Date) return JSON.stringify(obj.toISOString()); + if (typeof obj !== 'object') return JSON.stringify(obj); + if (Array.isArray(obj)) { + // Indexed rather than mapped: Array.prototype.map skips holes, which would + // collapse a sparse array into fewer elements than JSON.stringify emits. + const items = []; + for (let i = 0; i < obj.length; i++) { + items.push(stableStringify(obj[i])); + } + return '[' + items.join(',') + ']'; + } + const sortedKeys = Object.keys(obj).sort(); + const parts = []; + for (const key of sortedKeys) { + if (obj[key] !== undefined) { + parts.push(JSON.stringify(key) + ':' + stableStringify(obj[key])); + } + } + return '{' + parts.join(',') + '}'; +} diff --git a/frontend/src/services/relay/relayClient.js b/frontend/src/services/relay/relayClient.js new file mode 100644 index 00000000..93952a28 --- /dev/null +++ b/frontend/src/services/relay/relayClient.js @@ -0,0 +1,91 @@ +import { RelayClient } from '@aossie-org/thrubox-client'; + +/** Path the Vite dev server proxies to the relay. Keep in sync with vite.config.js. */ +export const RELAY_PROXY_PATH = '/relay'; + +const DEFAULT_RELAY_URL = 'http://localhost:3000'; +const DEFAULT_TIMEOUT_MS = 15_000; +const RETRIES = 3; + +let client = null; + +/** + * Request timeout, overridable via VITE_RELAY_TIMEOUT_MS. + * + * Worth raising on hosts that suspend idle instances: a cold start can take + * the better part of a minute, and the SDK deliberately does not retry POSTs + * (a retried send would duplicate the message), so one timed-out request is + * one invoice that failed to reach its recipient. + */ +function resolveTimeout() { + const raw = Number(import.meta.env.VITE_RELAY_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS; +} + +/** + * Work out which URL the browser should talk to. + * + * The relay serves no CORS headers, so a direct cross-origin request from the + * browser fails its preflight. There are two ways around that, and both are + * supported here: + * + * - Proxy it, so the browser never makes a cross-origin request at all. In + * development the Vite dev server does this at /relay. In production, set + * VITE_RELAY_URL to a path rather than a URL (e.g. "/relay") and let the + * host rewrite it — Vercel `rewrites`, Netlify `redirects`, nginx + * `proxy_pass`. No CORS needed. + * - Address the relay directly with an absolute VITE_RELAY_URL, which + * requires the relay to serve CORS headers for your origin. + * + * @returns {string} absolute base URL for RelayClient + */ +function resolveBaseUrl() { + const configured = (import.meta.env.VITE_RELAY_URL || '').trim(); + + if (typeof window !== 'undefined') { + if (import.meta.env.DEV) { + return `${window.location.origin}${RELAY_PROXY_PATH}`; + } + // A path rather than a URL means "same origin, the host proxies it". + if (configured.startsWith('/')) { + return `${window.location.origin}${configured.replace(/\/+$/, '')}`; + } + } + + return configured || DEFAULT_RELAY_URL; +} + +/** + * Get the shared RelayClient instance, creating it on first use. + * @returns {RelayClient} + */ +export function getRelayClient() { + if (!client) { + const apiKey = (import.meta.env.VITE_RELAY_API_KEY || '').trim(); + client = new RelayClient(resolveBaseUrl(), { + ...(apiKey ? { apiKey } : {}), + timeout: resolveTimeout(), + retries: RETRIES, + }); + } + return client; +} + +/** Drop the cached client. Only useful in tests. */ +export function resetRelayClient() { + client = null; +} + +/** + * Check whether the relay is reachable. + * @returns {Promise} + */ +export async function isRelayHealthy() { + try { + const health = await getRelayClient().health(); + return health?.status === 'ok'; + } catch (err) { + console.warn('[RelayClient] Health check failed:', err); + return false; + } +} diff --git a/frontend/src/services/relay/relayInvoiceMessaging.js b/frontend/src/services/relay/relayInvoiceMessaging.js new file mode 100644 index 00000000..b659fc09 --- /dev/null +++ b/frontend/src/services/relay/relayInvoiceMessaging.js @@ -0,0 +1,223 @@ +import { getRelayClient } from './relayClient.js'; +import { encryptPayload, tryDecryptPayload } from './invoiceCrypto.js'; + +const DEFAULT_POLL_INTERVAL_MS = 15_000; +const ENVELOPE_TYPE = 'invoice'; + +/** + * Normalise an address for relay addressing. + * + * The relay looks messages up by exact string match, so a message sent to a + * checksummed address is invisible to a client polling the lowercase form. + * Every address crossing the relay boundary goes through here. + * + * @param {string} address + * @returns {string} + */ +export function toMailboxAddress(address) { + if (!address) throw new Error('Relay address is required'); + return address.toLowerCase(); +} + +/** + * Build the envelope that gets encrypted and handed to the relay. + * + * @param {Object} invoiceData - plaintext invoice payload + * @param {number|string} chainId + * @param {number|string} invoiceId + * @returns {Object} + */ +function buildEnvelope(invoiceData, chainId, invoiceId) { + // Fail loudly here rather than shipping a broken envelope. A non-numeric + // chainId becomes NaN, JSON-serialises to null, and never matches the + // receiver's `Number(envelope.chainId) !== Number(chainId)` check — the + // message would be encrypted, accepted by the relay, and silently ignored + // forever, with the sender told it succeeded. + // Positive check rather than just finite: Number(null) and Number('') are + // both 0, which is finite but not a real chain. + const numericChainId = Number(chainId); + if (!Number.isFinite(numericChainId) || numericChainId <= 0) { + throw new Error(`Invalid chainId for relay envelope: ${chainId}`); + } + if (invoiceId === null || invoiceId === undefined || invoiceId === '') { + throw new Error('Invalid invoiceId for relay envelope'); + } + + return { + type: ENVELOPE_TYPE, + invoiceId: invoiceId.toString(), + chainId: numericChainId, + timestamp: Date.now(), + data: invoiceData, + }; +} + +/** + * Encrypt an invoice for its recipient and post it to the relay. + * + * `invoiceData` must be exactly the object that was hashed into the + * on-chain `invoiceDataHash`. The receiver recomputes the hash over what + * arrives here and rejects it on mismatch, so any field added, dropped or + * reordered on the way in will fail verification on the way out. + * + * @param {Object} params + * @param {Object} params.invoiceData - plaintext invoice payload (as hashed) + * @param {Uint8Array|string} params.receiverPublicKey - from the on-chain registry + * @param {string} params.receiverAddress - recipient wallet address + * @param {string} params.senderAddress - sender wallet address + * @param {number|string} params.chainId + * @param {number|string} params.invoiceId - on-chain invoice ID + * @returns {Promise} + */ +export async function sendEncryptedInvoice({ + invoiceData, + receiverPublicKey, + receiverAddress, + senderAddress, + chainId, + invoiceId, +}) { + if (!receiverPublicKey) { + throw new Error('Recipient has not registered a public key'); + } + + const envelope = buildEnvelope(invoiceData, chainId, invoiceId); + const payload = encryptPayload(receiverPublicKey, envelope); + + return getRelayClient().send({ + to: toMailboxAddress(receiverAddress), + from: toMailboxAddress(senderAddress), + payload, + }); +} + +/** + * Decrypt the relay messages that belong to this user on this chain. + * + * Undecryptable messages are skipped: the mailbox is public, so anyone can + * drop anything into it, and that is expected rather than exceptional. + * + * Nothing returned here is authenticated. The relay does not verify who + * posted a message, and the envelope carries no sender signature — decrypting + * successfully only proves the sender knew the recipient's public key, which + * is published on-chain. Anyone can therefore deliver a well-formed envelope + * claiming any sender and any contents. + * + * Callers must treat `claimedFrom` and `envelope.data` as untrusted until + * `envelope.data` has been checked against the on-chain `invoiceDataHash` for + * `envelope.invoiceId` (see verifyInvoiceHash). That check is what makes the + * payload trustworthy, and it needs a chain read this module does not perform. + * + * @param {Array} messages - raw relay messages + * @param {Uint8Array|string} privateKey + * @param {number|string} chainId + * @returns {Array<{messageId: string, claimedFrom: string, envelope: Object}>} + */ +function decodeMessages(messages, privateKey, chainId) { + const decoded = []; + for (const message of messages) { + if (!message?.payload) continue; + + const envelope = tryDecryptPayload(privateKey, message.payload); + if (!envelope) continue; + + if (envelope.type !== ENVELOPE_TYPE) continue; + if (Number(envelope.chainId) !== Number(chainId)) continue; + if (!envelope.invoiceId || !envelope.data) continue; + + decoded.push({ + messageId: message.id, + // Relay metadata, set by whoever posted the message. Unverified. + claimedFrom: message.from, + envelope, + }); + } + return decoded; +} + +/** + * Fetch and decrypt every invoice currently waiting in the user's mailbox. + * + * Replaces the Waku Store query: the relay retains messages for its + * configured TTL, so this is how a client catches up after being offline. + * + * @param {Object} params + * @param {Uint8Array|string} params.privateKey + * @param {string} params.address - the user's wallet address + * @param {number|string} params.chainId + * @returns {Promise>} + */ +export async function fetchInvoiceMessages({ privateKey, address, chainId }) { + const messages = await getRelayClient().receive(toMailboxAddress(address)); + return decodeMessages(messages, privateKey, chainId); +} + +/** + * Poll the relay for new invoices. + * + * The relay has no cursor — every poll returns the full mailbox — so this + * tracks the message IDs it has already surfaced and only invokes + * `onInvoice` for ones it has not seen. Messages are deliberately left on + * the relay until they expire, so a second device can still pick them up. + * + * @param {Object} params + * @param {Uint8Array|string} params.privateKey + * @param {string} params.address - the user's wallet address + * @param {number|string} params.chainId + * @param {(item: {messageId: string, claimedFrom: string, envelope: Object}) => void|Promise} params.onInvoice + * @param {(error: Error) => void} [params.onError] + * @param {number} [params.intervalMs] + * @param {Iterable} [params.knownMessageIds] - IDs to treat as already seen + * @returns {() => void} stop function + */ +export function pollInvoiceMessages({ + privateKey, + address, + chainId, + onInvoice, + onError, + intervalMs = DEFAULT_POLL_INTERVAL_MS, + knownMessageIds = [], +}) { + const seen = new Set(knownMessageIds); + let stopped = false; + + const stopPolling = getRelayClient().poll( + toMailboxAddress(address), + async (messages) => { + if (stopped) return; + for (const item of decodeMessages(messages, privateKey, chainId)) { + if (stopped) return; + if (seen.has(item.messageId)) continue; + + // Claim before awaiting, release if the handler fails. The SDK does + // not await this callback before scheduling the next poll, so two + // polls can overlap: claiming up front stops both from processing the + // same message, and releasing on failure stops a transient error — + // a failed IndexedDB write, say — from burying the invoice for good. + seen.add(item.messageId); + try { + await onInvoice(item); + } catch (err) { + seen.delete(item.messageId); + console.warn('[RelayInvoiceMessaging] onInvoice handler failed, will retry:', err); + } + } + }, + { + intervalMs, + onError: (err) => { + if (stopped) return; + if (onError) onError(err); + else console.warn('[RelayInvoiceMessaging] Poll failed:', err); + }, + } + ); + + return () => { + stopped = true; + stopPolling(); + }; +} + +export { DEFAULT_POLL_INTERVAL_MS }; diff --git a/frontend/src/services/relay/relayKeyManager.js b/frontend/src/services/relay/relayKeyManager.js new file mode 100644 index 00000000..cf290415 --- /dev/null +++ b/frontend/src/services/relay/relayKeyManager.js @@ -0,0 +1,313 @@ +import { ethers } from 'ethers'; + +/** + * Message the user signs to derive their messaging keypair. + * + * Do not change this string. The derived public key is what users have + * already registered in the on-chain registry; a different message derives + * a different key, silently breaking decryption for everyone who registered + * before the change. It is named after Waku for historical reasons only — + * the derivation itself is transport-independent. + */ +const DERIVATION_MESSAGE = 'ChainVoice Waku Key Derivation v1'; +const KEY_STORAGE_PREFIX = 'chainvoice_relay_keys_'; + +/** secp256k1 key sizes, as the on-chain registry validates them. */ +const PRIVATE_KEY_BYTES = 32; +const PUBLIC_KEY_BYTES = 65; +const UNCOMPRESSED_PREFIX = 0x04; + +/** In-memory cache for derived keys (keyed by lowercase address). */ +const memoryCache = new Map(); + +/** + * Derivations currently awaiting a signature, keyed by lowercase address. + * + * The cache is only populated once signMessage resolves, so two callers racing + * before that both miss it and both open a wallet prompt. Sharing the in-flight + * promise means the second caller waits on the first signature instead. + */ +const inFlightDerivations = new Map(); + +/** + * Bumped whenever an address is cleared. A derivation captures the value it + * started with and refuses to write its result if it no longer matches, so a + * signature still pending at the moment of a disconnect cannot restore the key + * afterwards. + */ +const cacheGenerations = new Map(); + +function currentGeneration(cacheKey) { + return cacheGenerations.get(cacheKey) ?? 0; +} + +/** + * Convert hex string to Uint8Array. + * @param {string} hex + * @returns {Uint8Array} + */ +function hexToBytes(hex) { + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + if (cleanHex.length % 2 !== 0) throw new Error('Invalid hex string: odd length'); + if (!/^[0-9a-fA-F]*$/.test(cleanHex)) throw new Error('Invalid hex string: non-hex characters'); + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +/** + * Convert Uint8Array to hex string (0x-prefixed). + * @param {Uint8Array} bytes + * @returns {string} + */ +function bytesToHex(bytes) { + return '0x' + Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Derive an ECIES keypair from a wallet signature. + * + * The user signs a deterministic message, and we use keccak256 of the + * signature as the 32-byte private key for secp256k1. This ensures the + * same wallet always derives the same key pair, so the key never has to + * be stored anywhere durable. + * + * @param {import('ethers').Signer} signer - ethers v6 signer + * @param {string} address - wallet address + * @param {boolean} [rememberSession=false] - if true, cache keys in sessionStorage; if false, keep in memory only + * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} + */ +export async function deriveRelayKeyPair(signer, address, rememberSession = false) { + // Check in-memory cache first (avoids re-derivation within the same page session) + const memCached = getMemoryCachedKeys(address); + if (memCached) return memCached; + + // Then check sessionStorage + const sessionCached = getSessionCachedKeys(address); + if (sessionCached) { + // Populate memory cache so subsequent lookups are instant + setMemoryCachedKeys(address, sessionCached); + return sessionCached; + } + + // Join an in-flight derivation for this address rather than starting a + // second one, so concurrent callers never produce two signature prompts. + const cacheKey = address.toLowerCase(); + const pending = inFlightDerivations.get(cacheKey); + if (pending) return pending; + + const startedAtGeneration = currentGeneration(cacheKey); + const derivation = (async () => { + // Sign deterministic message to derive keys + const signature = await signer.signMessage(DERIVATION_MESSAGE); + + // Use keccak256 of the raw signature bytes as the private key (32 bytes) + const privateKeyHex = ethers.keccak256(signature); + const privateKey = hexToBytes(privateKeyHex); + + // Derive uncompressed public key (65 bytes: 0x04 + x + y) + const signingKey = new ethers.SigningKey(privateKeyHex); + const publicKey = hexToBytes(signingKey.publicKey); + + const keyPair = { privateKey, publicKey }; + + // The key was cleared while this signature was pending — the account was + // switched or disconnected. Hand the value back to whoever asked, but do + // not resurrect it in any cache. + if (currentGeneration(cacheKey) !== startedAtGeneration) { + return keyPair; + } + + // Always store in memory cache + setMemoryCachedKeys(address, keyPair); + + // Optionally persist to sessionStorage + if (rememberSession) { + cacheKeysToSession(address, privateKey, publicKey); + } + + return keyPair; + })(); + + inFlightDerivations.set(cacheKey, derivation); + try { + return await derivation; + } finally { + inFlightDerivations.delete(cacheKey); + } +} + +/** + * Store a key pair in the in-memory cache. + * @param {string} address + * @param {{privateKey: Uint8Array, publicKey: Uint8Array}} keyPair + */ +function setMemoryCachedKeys(address, keyPair) { + memoryCache.set(address.toLowerCase(), keyPair); +} + +/** + * Retrieve a key pair from the in-memory cache. + * @param {string} address + * @returns {{privateKey: Uint8Array, publicKey: Uint8Array}|null} + */ +function getMemoryCachedKeys(address) { + return memoryCache.get(address.toLowerCase()) || null; +} + +/** + * Cache derived keys in sessionStorage for the current session. + * @param {string} address + * @param {Uint8Array} privateKey + * @param {Uint8Array} publicKey + */ +function cacheKeysToSession(address, privateKey, publicKey) { + try { + const data = { + privateKey: bytesToHex(privateKey), + publicKey: bytesToHex(publicKey), + }; + sessionStorage.setItem( + KEY_STORAGE_PREFIX + address.toLowerCase(), + JSON.stringify(data) + ); + } catch (e) { + console.warn('[RelayKeyManager] Failed to cache keys:', e); + } +} + +/** + * Retrieve cached keys from sessionStorage. + * @param {string} address + * @returns {{privateKey: Uint8Array, publicKey: Uint8Array}|null} + */ +function getSessionCachedKeys(address) { + try { + const raw = sessionStorage.getItem( + KEY_STORAGE_PREFIX + address.toLowerCase() + ); + if (!raw) return null; + const data = JSON.parse(raw); + const privateKey = hexToBytes(data.privateKey); + const publicKey = hexToBytes(data.publicKey); + + // A truncated entry still parses as valid hex, and a wrong-sized key fails + // every decryption silently — tryDecryptPayload swallows the error, so the + // inbox would just stop working with nothing in the console. Treat it as a + // miss and drop it so the next call re-derives. + if ( + privateKey.length !== PRIVATE_KEY_BYTES || + publicKey.length !== PUBLIC_KEY_BYTES || + publicKey[0] !== UNCOMPRESSED_PREFIX + ) { + console.warn('[RelayKeyManager] Discarding malformed cached key'); + sessionStorage.removeItem(KEY_STORAGE_PREFIX + address.toLowerCase()); + return null; + } + + return { privateKey, publicKey }; + } catch { + return null; + } +} + +/** + * Clear cached keys for a given address from both memory and sessionStorage. + * @param {string} address + */ +export function clearCachedKeys(address) { + if (!address) return; + const key = address.toLowerCase(); + memoryCache.delete(key); + inFlightDerivations.delete(key); + cacheGenerations.set(key, currentGeneration(key) + 1); + try { + sessionStorage.removeItem(KEY_STORAGE_PREFIX + key); + } catch { + // Ignore errors (e.g. SSR environments) + } +} + +/** + * Read an already-derived key pair without ever signing. + * + * Deriving needs a wallet signature, so callers that only want to restore an + * existing session — a page reload, say — must not fall through to + * derivation: that would pop an unexplained signature prompt on load. This + * returns null instead when nothing is cached. + * + * @param {string} address + * @returns {{privateKey: Uint8Array, publicKey: Uint8Array}|null} + */ +export function getCachedKeyPair(address) { + if (!address) return null; + + const memCached = getMemoryCachedKeys(address); + if (memCached) return memCached; + + const sessionCached = getSessionCachedKeys(address); + if (sessionCached) { + // Promote to the memory cache so later reads skip the parse. + setMemoryCachedKeys(address, sessionCached); + return sessionCached; + } + + return null; +} + +/** + * Check if the keys are already cached in memory or session storage. + * @param {string} address + * @returns {boolean} + */ +export function hasCachedKeys(address) { + return getCachedKeyPair(address) !== null; +} + +/** + * Register the user's messaging public key on-chain. + * + * The contract method is still named `registerWakuPublicKey`; it is a + * transport-agnostic secp256k1 key registry and is already deployed, so + * the name is kept as-is. + * + * @param {import('ethers').Contract} contract - Chainvoice contract instance + * @param {Uint8Array} publicKey - the user's public key + * @returns {Promise} + */ +export async function registerPublicKeyOnChain(contract, publicKey) { + const tx = await contract.registerWakuPublicKey(bytesToHex(publicKey)); + return await tx.wait(); +} + +/** + * Fetch a user's messaging public key from the on-chain registry. + * + * @param {import('ethers').Contract} contract - Chainvoice contract instance + * @param {string} userAddress - the address to look up + * @returns {Promise} - the public key bytes, or null if not registered + */ +export async function fetchPublicKeyFromChain(contract, userAddress) { + const keyHex = await contract.getWakuPublicKey(userAddress); + if (!keyHex || keyHex === '0x' || keyHex === '0x0' || keyHex.length <= 2) { + return null; + } + + const publicKey = hexToBytes(keyHex); + // The current contract enforces this on registration, but an older or + // misconfigured deployment need not. Failing here beats handing a malformed + // key to eciesjs and surfacing its internal error instead. + if ( + publicKey.length !== PUBLIC_KEY_BYTES || + publicKey[0] !== UNCOMPRESSED_PREFIX + ) { + throw new Error( + `Registry returned a malformed public key for ${userAddress}: expected ${PUBLIC_KEY_BYTES} bytes starting 0x04, got ${publicKey.length} bytes` + ); + } + return publicKey; +} + +export { hexToBytes, bytesToHex, DERIVATION_MESSAGE }; diff --git a/frontend/tests/services/invoiceCrypto.test.js b/frontend/tests/services/invoiceCrypto.test.js new file mode 100644 index 00000000..5da99ba8 --- /dev/null +++ b/frontend/tests/services/invoiceCrypto.test.js @@ -0,0 +1,89 @@ +import { ethers } from "ethers"; +import { + encryptPayload, + decryptPayload, + tryDecryptPayload, + bytesToBase64, + base64ToBytes, +} from "../../src/services/relay/invoiceCrypto.js"; + +/** Derive a keypair the same way relayKeyManager does, without a wallet. */ +function keyPairFrom(seed) { + const privateKeyHex = ethers.keccak256(ethers.toUtf8Bytes(seed)); + return { + privateKey: privateKeyHex, + publicKey: new ethers.SigningKey(privateKeyHex).publicKey, + }; +} + +const receiver = keyPairFrom("receiver"); +const stranger = keyPairFrom("stranger"); + +const envelope = { + type: "invoice", + invoiceId: "7", + chainId: 11155111, + data: { amountDue: "125.5", client: { email: "bob@example.com" } }, +}; + +describe("base64 helpers", () => { + it("round-trips arbitrary bytes", () => { + const bytes = new Uint8Array([0, 1, 127, 128, 255, 42]); + expect(Array.from(base64ToBytes(bytesToBase64(bytes)))).toEqual( + Array.from(bytes) + ); + }); + + it("handles payloads larger than one chunk", () => { + const bytes = new Uint8Array(100_000).map((_, i) => i % 256); + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes); + }); +}); + +describe("encryptPayload / decryptPayload", () => { + it("round-trips an invoice envelope", () => { + const ciphertext = encryptPayload(receiver.publicKey, envelope); + expect(decryptPayload(receiver.privateKey, ciphertext)).toEqual(envelope); + }); + + it("produces base64 that does not leak plaintext", () => { + const ciphertext = encryptPayload(receiver.publicKey, envelope); + expect(ciphertext).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(ciphertext).not.toContain("bob@example.com"); + }); + + it("is non-deterministic (fresh ephemeral key per message)", () => { + const a = encryptPayload(receiver.publicKey, envelope); + const b = encryptPayload(receiver.publicKey, envelope); + expect(a).not.toBe(b); + }); + + it("serialises bigints rather than throwing", () => { + const ciphertext = encryptPayload(receiver.publicKey, { amount: 10n }); + expect(decryptPayload(receiver.privateKey, ciphertext)).toEqual({ + amount: "10", + }); + }); + + it("rejects decryption with the wrong key", () => { + const ciphertext = encryptPayload(receiver.publicKey, envelope); + expect(() => decryptPayload(stranger.privateKey, ciphertext)).toThrow(); + }); +}); + +describe("tryDecryptPayload", () => { + it("returns the value when the key matches", () => { + const ciphertext = encryptPayload(receiver.publicKey, envelope); + expect(tryDecryptPayload(receiver.privateKey, ciphertext)).toEqual(envelope); + }); + + it("returns null for another recipient's message", () => { + const ciphertext = encryptPayload(receiver.publicKey, envelope); + expect(tryDecryptPayload(stranger.privateKey, ciphertext)).toBeNull(); + }); + + it("returns null for junk in the mailbox", () => { + expect(tryDecryptPayload(receiver.privateKey, "bm90LWNpcGhlcnRleHQ=")).toBeNull(); + expect(tryDecryptPayload(receiver.privateKey, "!!!not base64!!!")).toBeNull(); + }); +}); diff --git a/frontend/tests/services/invoiceHashUtils.test.js b/frontend/tests/services/invoiceHashUtils.test.js new file mode 100644 index 00000000..768435f4 --- /dev/null +++ b/frontend/tests/services/invoiceHashUtils.test.js @@ -0,0 +1,169 @@ +import { + computeInvoiceHash, + verifyInvoiceHash, + stableStringify, +} from "../../src/services/relay/invoiceHashUtils.js"; + +const invoice = { + amountDue: "125.5", + paymentToken: { address: "0xabc", symbol: "USDC", decimals: 6 }, + user: { fname: "Ada", email: "ada@example.com" }, + client: { fname: "Bob", email: "bob@example.com" }, + items: [{ description: "Consulting", qty: "2", unitPrice: "50" }], +}; + +describe("stableStringify", () => { + it("is insensitive to key insertion order", () => { + const a = { x: 1, y: { p: 2, q: 3 } }; + const b = { y: { q: 3, p: 2 }, x: 1 }; + expect(stableStringify(a)).toBe(stableStringify(b)); + }); + + it("preserves array order", () => { + expect(stableStringify([1, 2])).not.toBe(stableStringify([2, 1])); + }); + + it("serialises bigints as strings", () => { + expect(stableStringify({ n: 10n })).toBe('{"n":"10"}'); + }); + + it("serialises dates as ISO strings", () => { + const date = new Date("2024-01-15T00:00:00.000Z"); + expect(stableStringify({ d: date })).toBe('{"d":"2024-01-15T00:00:00.000Z"}'); + }); + + it("omits undefined values", () => { + expect(stableStringify({ a: 1, b: undefined })).toBe('{"a":1}'); + }); + + // The payload travels to the receiver as JSON, so anything stableStringify + // renders differently to JSON.stringify makes the two sides compute + // different hashes and silently fails verification. + describe("agrees with JSON.stringify on array edge cases", () => { + it("renders an undefined entry as null", () => { + expect(stableStringify([undefined])).toBe(JSON.stringify([undefined])); + expect(stableStringify([undefined])).toBe("[null]"); + }); + + it("does not collide [undefined] with []", () => { + expect(stableStringify([undefined])).not.toBe(stableStringify([])); + }); + + it("renders sparse holes as null", () => { + const sparse = new Array(2); + sparse[1] = 1; + expect(stableStringify(sparse)).toBe(JSON.stringify(sparse)); + expect(stableStringify(sparse)).toBe("[null,1]"); + }); + + it("renders a trailing hole as null", () => { + const sparse = new Array(2); + sparse[0] = 1; + expect(stableStringify(sparse)).toBe(JSON.stringify(sparse)); + }); + + it("renders nulls the same either way", () => { + expect(stableStringify([null, 1])).toBe(JSON.stringify([null, 1])); + }); + }); +}); + +describe("computeInvoiceHash", () => { + it("returns a 32-byte hex hash", () => { + expect(computeInvoiceHash(invoice)).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it("is deterministic across key orderings", () => { + const reordered = { + items: invoice.items, + client: invoice.client, + user: invoice.user, + paymentToken: invoice.paymentToken, + amountDue: invoice.amountDue, + }; + expect(computeInvoiceHash(reordered)).toBe(computeInvoiceHash(invoice)); + }); + + it("changes when any field changes", () => { + const tampered = { ...invoice, amountDue: "125.6" }; + expect(computeInvoiceHash(tampered)).not.toBe(computeInvoiceHash(invoice)); + }); + + it("changes when a nested field changes", () => { + const tampered = { + ...invoice, + client: { ...invoice.client, email: "mallory@example.com" }, + }; + expect(computeInvoiceHash(tampered)).not.toBe(computeInvoiceHash(invoice)); + }); +}); + +describe("sender/receiver hash contract", () => { + // The sender hashes an in-memory object; the receiver hashes what comes back + // out of the relay's JSON. Anything that survives one but not the other makes + // the two sides disagree and silently fails verification, so assert over the + // round trip rather than over stableStringify alone. + const overTheWire = (value) => + JSON.parse( + JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v)) + ); + + it("survives Date fields becoming ISO strings", () => { + const payload = { + issueDate: new Date("2026-08-06T07:00:00.000Z"), + dueDate: new Date("2026-09-01T10:30:00.000Z"), + }; + expect(typeof overTheWire(payload).issueDate).toBe("string"); + expect(computeInvoiceHash(overTheWire(payload))).toBe( + computeInvoiceHash(payload) + ); + }); + + it("survives bigints becoming strings", () => { + const payload = { amountWei: 10n ** 18n }; + expect(computeInvoiceHash(overTheWire(payload))).toBe( + computeInvoiceHash(payload) + ); + }); + + it("survives undefined array entries becoming null", () => { + const payload = { items: [{ qty: "1" }, undefined] }; + expect(computeInvoiceHash(overTheWire(payload))).toBe( + computeInvoiceHash(payload) + ); + }); + + it("survives undefined object values being dropped", () => { + const payload = { user: { fname: "Ada", lname: undefined } }; + expect(computeInvoiceHash(overTheWire(payload))).toBe( + computeInvoiceHash(payload) + ); + }); + + it("survives a full invoice payload round trip", () => { + expect(computeInvoiceHash(overTheWire(invoice))).toBe( + computeInvoiceHash(invoice) + ); + }); +}); + +describe("verifyInvoiceHash", () => { + it("accepts matching data", () => { + expect(verifyInvoiceHash(invoice, computeInvoiceHash(invoice))).toBe(true); + }); + + it("is case-insensitive on the expected hash", () => { + const upper = computeInvoiceHash(invoice).toUpperCase().replace("0X", "0x"); + expect(verifyInvoiceHash(invoice, upper)).toBe(true); + }); + + it("rejects tampered data", () => { + const expected = computeInvoiceHash(invoice); + expect(verifyInvoiceHash({ ...invoice, amountDue: "999" }, expected)).toBe(false); + }); + + it("rejects a missing hash", () => { + expect(verifyInvoiceHash(invoice, undefined)).toBe(false); + expect(verifyInvoiceHash(invoice, "")).toBe(false); + }); +}); diff --git a/frontend/tests/services/relayInvoiceMessaging.test.js b/frontend/tests/services/relayInvoiceMessaging.test.js new file mode 100644 index 00000000..55f5db80 --- /dev/null +++ b/frontend/tests/services/relayInvoiceMessaging.test.js @@ -0,0 +1,521 @@ +import { jest } from "@jest/globals"; +import { ethers } from "ethers"; + +// relayClient.js reads import.meta.env, which Jest cannot evaluate, and we want +// a fake transport anyway — so stub the whole module before importing the SUT. +const relayApi = { + send: jest.fn(), + receive: jest.fn(), + poll: jest.fn(), +}; + +jest.unstable_mockModule("../../src/services/relay/relayClient.js", () => ({ + getRelayClient: () => relayApi, + RELAY_PROXY_PATH: "/relay", +})); + +const { encryptPayload, decryptPayload } = await import( + "../../src/services/relay/invoiceCrypto.js" +); +const { + sendEncryptedInvoice, + fetchInvoiceMessages, + pollInvoiceMessages, + toMailboxAddress, + DEFAULT_POLL_INTERVAL_MS, +} = await import("../../src/services/relay/relayInvoiceMessaging.js"); + +function keyPairFrom(seed) { + const privateKey = ethers.keccak256(ethers.toUtf8Bytes(seed)); + return { privateKey, publicKey: new ethers.SigningKey(privateKey).publicKey }; +} + +const receiver = keyPairFrom("receiver"); +const stranger = keyPairFrom("stranger"); + +const RECEIVER_ADDR = "0xF37B0B9f97e3B3a843d75Ef37F4eE8568590C900"; +const SENDER_ADDR = "0x69fF0f180e74112cF707DdDEC729095631c4B809"; +const CHAIN_ID = 11155111; + +const invoiceData = { amountDue: "125.5", client: { email: "bob@example.com" } }; + +/** Build a relay message carrying an envelope encrypted for `publicKey`. */ +function relayMessage(id, publicKey, overrides = {}) { + const envelope = { + type: "invoice", + invoiceId: "7", + chainId: CHAIN_ID, + timestamp: 1, + data: invoiceData, + ...overrides, + }; + return { + id, + to: toMailboxAddress(RECEIVER_ADDR), + from: toMailboxAddress(SENDER_ADDR), + payload: encryptPayload(publicKey, envelope), + }; +} + +beforeEach(() => { + relayApi.send.mockReset(); + relayApi.receive.mockReset(); + relayApi.poll.mockReset(); +}); + +describe("toMailboxAddress", () => { + it("lowercases addresses so relay lookups match", () => { + // The relay matches `to` by exact string, so a checksummed address would + // be invisible to a client polling the lowercase form. + expect(toMailboxAddress(RECEIVER_ADDR)).toBe(RECEIVER_ADDR.toLowerCase()); + }); + + it.each(["", null, undefined])("throws on a missing address (%p)", (value) => { + expect(() => toMailboxAddress(value)).toThrow(/address is required/i); + }); +}); + +describe("sendEncryptedInvoice", () => { + it("posts a ciphertext addressed to lowercased participants", async () => { + relayApi.send.mockResolvedValue({ id: "msg-1" }); + + await sendEncryptedInvoice({ + invoiceData, + receiverPublicKey: receiver.publicKey, + receiverAddress: RECEIVER_ADDR, + senderAddress: SENDER_ADDR, + chainId: CHAIN_ID, + invoiceId: 7, + }); + + expect(relayApi.send).toHaveBeenCalledTimes(1); + const sent = relayApi.send.mock.calls[0][0]; + expect(sent.to).toBe(RECEIVER_ADDR.toLowerCase()); + expect(sent.from).toBe(SENDER_ADDR.toLowerCase()); + expect(sent.payload).not.toContain("bob@example.com"); + + // Assert the envelope the send path actually built, rather than trusting + // the fixture to mirror it — the receiver's chain filter and dedupe key + // both depend on these exact fields. + const envelope = decryptPayload(receiver.privateKey, sent.payload); + expect(envelope).toMatchObject({ + type: "invoice", + invoiceId: "7", + chainId: CHAIN_ID, + data: invoiceData, + }); + expect(typeof envelope.invoiceId).toBe("string"); + expect(typeof envelope.timestamp).toBe("number"); + }); + + // A NaN chainId serialises to null and never matches the receiver's chain + // filter, so the message would be accepted by the relay and silently + // discarded forever while the sender is told it succeeded. + it.each([undefined, null, "not-a-chain", NaN])( + "refuses to build an envelope with chainId %p", + async (chainId) => { + await expect( + sendEncryptedInvoice({ + invoiceData, + receiverPublicKey: receiver.publicKey, + receiverAddress: RECEIVER_ADDR, + senderAddress: SENDER_ADDR, + chainId, + invoiceId: 7, + }) + ).rejects.toThrow(/chainId/i); + expect(relayApi.send).not.toHaveBeenCalled(); + } + ); + + it.each([undefined, null, ""])( + "refuses to build an envelope with invoiceId %p", + async (invoiceId) => { + await expect( + sendEncryptedInvoice({ + invoiceData, + receiverPublicKey: receiver.publicKey, + receiverAddress: RECEIVER_ADDR, + senderAddress: SENDER_ADDR, + chainId: CHAIN_ID, + invoiceId, + }) + ).rejects.toThrow(/invoiceId/i); + expect(relayApi.send).not.toHaveBeenCalled(); + } + ); + + it("accepts invoice id 0", async () => { + // Falsy but valid — the contract numbers the first invoice 0. + relayApi.send.mockResolvedValue({ id: "msg-1" }); + await sendEncryptedInvoice({ + invoiceData, + receiverPublicKey: receiver.publicKey, + receiverAddress: RECEIVER_ADDR, + senderAddress: SENDER_ADDR, + chainId: CHAIN_ID, + invoiceId: 0, + }); + expect(relayApi.send).toHaveBeenCalledTimes(1); + }); + + it("refuses to send without a recipient key", async () => { + await expect( + sendEncryptedInvoice({ + invoiceData, + receiverPublicKey: null, + receiverAddress: RECEIVER_ADDR, + senderAddress: SENDER_ADDR, + chainId: CHAIN_ID, + invoiceId: 7, + }) + ).rejects.toThrow(/public key/i); + expect(relayApi.send).not.toHaveBeenCalled(); + }); +}); + +describe("fetchInvoiceMessages", () => { + it("decrypts messages addressed to this key", async () => { + relayApi.receive.mockResolvedValue([relayMessage("m1", receiver.publicKey)]); + + const result = await fetchInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + }); + + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + expect(result[0].envelope.data).toEqual(invoiceData); + }); + + it("queries the lowercased mailbox", async () => { + relayApi.receive.mockResolvedValue([]); + await fetchInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + }); + expect(relayApi.receive).toHaveBeenCalledWith(RECEIVER_ADDR.toLowerCase()); + }); + + it("skips messages it cannot decrypt", async () => { + relayApi.receive.mockResolvedValue([ + relayMessage("m1", stranger.publicKey), + { id: "m2", payload: "junk" }, + { id: "m3" }, + relayMessage("m4", receiver.publicKey), + ]); + + const result = await fetchInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + }); + + expect(result.map((r) => r.messageId)).toEqual(["m4"]); + }); + + it("filters out other chains", async () => { + relayApi.receive.mockResolvedValue([ + relayMessage("m1", receiver.publicKey, { chainId: 137 }), + relayMessage("m2", receiver.publicKey), + ]); + + const result = await fetchInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + }); + + expect(result.map((r) => r.messageId)).toEqual(["m2"]); + }); + + it("ignores envelopes of an unknown type or shape", async () => { + relayApi.receive.mockResolvedValue([ + relayMessage("m1", receiver.publicKey, { type: "something-else" }), + relayMessage("m2", receiver.publicKey, { data: undefined }), + relayMessage("m3", receiver.publicKey, { invoiceId: undefined }), + ]); + + const result = await fetchInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + }); + + expect(result).toEqual([]); + }); +}); + +describe("pollInvoiceMessages", () => { + /** Capture the callback and options the SDK's poll() would receive. */ + function capturePoll() { + let onMessages; + let options; + const stop = jest.fn(); + relayApi.poll.mockImplementation((address, cb, opts) => { + onMessages = cb; + options = opts; + return stop; + }); + return { + pump: (msgs) => onMessages(msgs), + stop, + getOptions: () => options, + }; + } + + it("forwards intervalMs to the SDK", () => { + const { getOptions } = capturePoll(); + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice: jest.fn(), + intervalMs: 5000, + }); + expect(getOptions().intervalMs).toBe(5000); + }); + + it("defaults intervalMs when not given", () => { + const { getOptions } = capturePoll(); + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice: jest.fn(), + }); + expect(getOptions().intervalMs).toBe(DEFAULT_POLL_INTERVAL_MS); + }); + + it("delegates poll failures to onError", () => { + const { getOptions } = capturePoll(); + const onError = jest.fn(); + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice: jest.fn(), + onError, + }); + + const failure = new Error("relay unreachable"); + getOptions().onError(failure); + + expect(onError).toHaveBeenCalledWith(failure); + }); + + it("suppresses errors raised after stopping", () => { + const { getOptions } = capturePoll(); + const onError = jest.fn(); + const stopPolling = pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice: jest.fn(), + onError, + }); + + stopPolling(); + getOptions().onError(new Error("in flight when stopped")); + + expect(onError).not.toHaveBeenCalled(); + }); + + it("emits each message only once across polls", async () => { + const { pump } = capturePoll(); + const onInvoice = jest.fn(); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + // The relay has no cursor, so every poll returns the whole mailbox. + const batch = [relayMessage("m1", receiver.publicKey)]; + await pump(batch); + await pump(batch); + + expect(onInvoice).toHaveBeenCalledTimes(1); + }); + + it("emits newly arrived messages on a later poll", async () => { + const { pump } = capturePoll(); + const onInvoice = jest.fn(); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + await pump([relayMessage("m1", receiver.publicKey)]); + await pump([ + relayMessage("m1", receiver.publicKey), + relayMessage("m2", receiver.publicKey), + ]); + + expect(onInvoice).toHaveBeenCalledTimes(2); + expect(onInvoice.mock.calls[1][0].messageId).toBe("m2"); + }); + + it("honours knownMessageIds so stored invoices are not re-announced", async () => { + const { pump } = capturePoll(); + const onInvoice = jest.fn(); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + knownMessageIds: ["m1"], + }); + + await pump([relayMessage("m1", receiver.publicKey)]); + expect(onInvoice).not.toHaveBeenCalled(); + }); + + it("stops emitting after the stop function is called", async () => { + const { pump, stop } = capturePoll(); + const onInvoice = jest.fn(); + + const stopPolling = pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + stopPolling(); + await pump([relayMessage("m1", receiver.publicKey)]); + + expect(stop).toHaveBeenCalled(); + expect(onInvoice).not.toHaveBeenCalled(); + }); + + it("does not double-process when two polls overlap", async () => { + // The SDK calls the poll callback without awaiting it before scheduling the + // next tick, so two cycles can run concurrently over the same mailbox. The + // messageId is claimed before the handler is awaited precisely so the + // second cycle skips it; without that, this delivers twice. + const { pump } = capturePoll(); + let releaseHandler; + const handlerStarted = new Promise((resolve) => { + releaseHandler = resolve; + }); + const onInvoice = jest.fn(() => handlerStarted); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + const batch = [relayMessage("m1", receiver.publicKey)]; + // Start both cycles before letting the first handler finish. + const first = pump(batch); + const second = pump(batch); + releaseHandler(); + await Promise.all([first, second]); + + expect(onInvoice).toHaveBeenCalledTimes(1); + }); + + it("retries a message whose handler threw", async () => { + // A transient failure — a rejected IndexedDB write, say — must not bury + // the invoice. The message stays unclaimed so the next poll re-delivers it. + const { pump } = capturePoll(); + const onInvoice = jest + .fn() + .mockRejectedValueOnce(new Error("indexeddb blew up")) + .mockResolvedValue(undefined); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + await pump([relayMessage("m1", receiver.publicKey)]); + await pump([relayMessage("m1", receiver.publicKey)]); + + const delivered = onInvoice.mock.calls.map((c) => c[0].messageId); + expect(delivered).toEqual(["m1", "m1"]); + }); + + it("does not re-deliver once the handler finally succeeds", async () => { + const { pump } = capturePoll(); + const onInvoice = jest + .fn() + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValue(undefined); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + const batch = [relayMessage("m1", receiver.publicKey)]; + await pump(batch); // fails, released + await pump(batch); // succeeds, claimed + await pump(batch); // already seen + + expect(onInvoice.mock.calls.map((c) => c[0].messageId)).toEqual(["m1", "m1"]); + }); + + it("stops mid-batch when the stop function is called from a handler", async () => { + // pollInvoiceMessages re-checks `stopped` inside the message loop; without + // that, the rest of the batch is still delivered after teardown. + const { pump } = capturePoll(); + let stopPolling; + const onInvoice = jest.fn(() => { + stopPolling(); + }); + + stopPolling = pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + await pump([ + relayMessage("m1", receiver.publicKey), + relayMessage("m2", receiver.publicKey), + relayMessage("m3", receiver.publicKey), + ]); + + expect(onInvoice).toHaveBeenCalledTimes(1); + expect(onInvoice.mock.calls[0][0].messageId).toBe("m1"); + }); + + it("keeps processing later messages after one handler throws", async () => { + const { pump } = capturePoll(); + const onInvoice = jest + .fn() + .mockRejectedValueOnce(new Error("indexeddb blew up")) + .mockResolvedValue(undefined); + + pollInvoiceMessages({ + privateKey: receiver.privateKey, + address: RECEIVER_ADDR, + chainId: CHAIN_ID, + onInvoice, + }); + + await pump([ + relayMessage("m1", receiver.publicKey), + relayMessage("m2", receiver.publicKey), + ]); + + expect(onInvoice.mock.calls.map((c) => c[0].messageId)).toEqual(["m1", "m2"]); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e4222e56..7d2dfbae 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,18 +1,35 @@ import path from "path" import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" +import { defineConfig, loadEnv } from "vite" import { nodePolyfills } from 'vite-plugin-node-polyfills' -export default defineConfig({ - plugins: [react(), nodePolyfills({ include: ['buffer', 'crypto', 'stream', 'util'] })], - base:'/', - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [react(), nodePolyfills({ include: ['buffer', 'crypto', 'stream', 'util'] })], + base:'/', + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + proxy: { + // The ThruBox relay serves no CORS headers, so the browser cannot reach + // it cross-origin: the preflight for POST /api/messages is rejected and + // GET responses carry no Access-Control-Allow-Origin. Proxying keeps dev + // requests same-origin. Production is expected to reach the relay + // through a reverse proxy that supplies CORS. + '/relay': { + target: env.VITE_RELAY_URL || 'http://localhost:3000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/relay/, ''), + }, + }, }, - }, - build: { - chunkSizeWarningLimit: 1500 + build: { + chunkSizeWarningLimit: 1500 + } } }) -