@profullstack/coinpay 0.6.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wallet.js CHANGED
@@ -33,6 +33,7 @@ export const WalletChain = {
33
33
  USDC_ETH: 'USDC_ETH',
34
34
  USDC_POL: 'USDC_POL',
35
35
  USDC_SOL: 'USDC_SOL',
36
+ USDC_BASE: 'USDC_BASE',
36
37
  USDT_ETH: 'USDT_ETH',
37
38
  USDT_POL: 'USDT_POL',
38
39
  USDT_SOL: 'USDT_SOL',
@@ -56,6 +57,7 @@ const COIN_TYPES = {
56
57
  USDC_ETH: 60,
57
58
  USDC_POL: 60,
58
59
  USDC_SOL: 501,
60
+ USDC_BASE: 60, // Base is EVM, uses ETH path — same address as USDC_ETH
59
61
  USDT_ETH: 60,
60
62
  USDT_POL: 60,
61
63
  USDT_SOL: 501,
@@ -64,7 +66,7 @@ const COIN_TYPES = {
64
66
  /**
65
67
  * Chains that use secp256k1 curve (vs ed25519 for Solana)
66
68
  */
67
- const SECP256K1_CHAINS = ['BTC', 'BCH', 'ETH', 'POL', 'BNB', 'USDC_ETH', 'USDC_POL', 'USDT_ETH', 'USDT_POL'];
69
+ const SECP256K1_CHAINS = ['BTC', 'BCH', 'ETH', 'POL', 'BNB', 'USDC_ETH', 'USDC_POL', 'USDC_BASE', 'USDT_ETH', 'USDT_POL'];
68
70
 
69
71
  // ============================================================
70
72
  // Address derivation helpers
@@ -305,6 +307,7 @@ export function deriveAddress(seed, chain, index = 0) {
305
307
  case 'BNB':
306
308
  case 'USDC_ETH':
307
309
  case 'USDC_POL':
310
+ case 'USDC_BASE':
308
311
  case 'USDT_ETH':
309
312
  case 'USDT_POL':
310
313
  return deriveETHAddress(publicKey);
@@ -419,6 +422,160 @@ function signMessage(message, privateKey) {
419
422
  return bytesToHex(signature.toCompactRawBytes());
420
423
  }
421
424
 
425
+ // ================================================================
426
+ // Arbitrary tx / message signing
427
+ // ----------------------------------------------------------------
428
+ // These primitives let callers (like b1dz.com's DEX engine) sign
429
+ // EVM contract calls and Solana transactions that CoinPay's built-in
430
+ // `send` flow doesn't cover (e.g., Uniswap swaps, Jupiter routes).
431
+ //
432
+ // The API intentionally stops at the cryptographic primitive:
433
+ // - EVM: caller builds the unsigned tx with their own library
434
+ // (viem / ethers / ethers-v5), hashes it per EIP-1559 or
435
+ // EIP-155, hands us the 32-byte digest, gets back a
436
+ // recoverable signature
437
+ // - Solana: caller builds the unsigned Transaction / VersionedMessage,
438
+ // hands us the raw message bytes, gets back an ed25519
439
+ // signature they can attach to the tx
440
+ //
441
+ // This keeps the crypto surface minimal and correct. We don't try to
442
+ // re-implement RLP, EIP-712, or Solana's transaction encoder inside
443
+ // CoinPay — those live in the caller's toolchain.
444
+ // ================================================================
445
+
446
+ /** Chains that sign via secp256k1 (32-byte digest → recoverable sig). */
447
+ function isSecp256k1Chain(chain) {
448
+ return SECP256K1_CHAINS.includes(chain);
449
+ }
450
+
451
+ /** Chains that sign via ed25519 (arbitrary message bytes → 64-byte sig). */
452
+ function isEd25519Chain(chain) {
453
+ return chain === 'SOL' || chain === 'USDC_SOL' || chain === 'USDT_SOL';
454
+ }
455
+
456
+ function normalizeHexInput(hex, label = 'hex') {
457
+ if (typeof hex !== 'string') throw new Error(`${label} must be a string`);
458
+ const stripped = hex.startsWith('0x') ? hex.slice(2) : hex;
459
+ if (stripped.length === 0) throw new Error(`${label} is empty`);
460
+ if (stripped.length % 2 !== 0) throw new Error(`${label} has odd length`);
461
+ if (!/^[0-9a-fA-F]+$/.test(stripped)) throw new Error(`${label} contains non-hex chars`);
462
+ return hexToBytes(stripped);
463
+ }
464
+
465
+ /**
466
+ * Sign a 32-byte digest with a secp256k1 chain's derived key.
467
+ * Returns a 65-byte recoverable signature formatted as `r(32) || s(32) || v(1)`
468
+ * where v is the low recovery id (0 or 1). This is the layout every
469
+ * EVM client (viem, ethers v6) expects for post-typed-transaction
470
+ * (EIP-1559 / EIP-2930) signatures.
471
+ *
472
+ * For legacy / EIP-155 transactions the caller is responsible for
473
+ * converting v to `chainId * 2 + 35 + recId` after receiving the sig.
474
+ * For EIP-191 (personal_sign) the caller adds 27.
475
+ *
476
+ * @param {Uint8Array} seed - Master seed
477
+ * @param {string} chain - BIP44 chain code (ETH, POL, BNB, ...)
478
+ * @param {Uint8Array} digest - Exactly 32 bytes
479
+ * @returns {string} `0x`-prefixed 65-byte hex signature
480
+ */
481
+ export function signDigestSecp256k1(seed, chain, digest) {
482
+ if (!isSecp256k1Chain(chain)) {
483
+ throw new Error(`chain ${chain} does not use secp256k1`);
484
+ }
485
+ if (!(digest instanceof Uint8Array) || digest.length !== 32) {
486
+ throw new Error('digest must be a 32-byte Uint8Array');
487
+ }
488
+ const { privateKey } = deriveKeyPair(seed, chain, 0);
489
+
490
+ // noble-curves has two incompatible signing APIs across major versions:
491
+ // v1.x: sign(hash, key) → Signature object with .r, .s, .recovery
492
+ // v2.x: sign(hash, key, { format: 'recovered' }) → 65-byte Uint8Array
493
+ // [recovery, r(32), s(32)]
494
+ // Detect at runtime so this works with whichever version is pinned.
495
+ // Requesting 'recovered' on v1 is harmless — v1 ignores the option
496
+ // and still returns a Signature object.
497
+ const raw = secp256k1.sign(digest, privateKey, { format: 'recovered' });
498
+ let r, s, recovery;
499
+ if (raw instanceof Uint8Array && raw.length === 65) {
500
+ recovery = raw[0] & 0x01;
501
+ r = raw.subarray(1, 33);
502
+ s = raw.subarray(33, 65);
503
+ } else if (raw && typeof raw === 'object' && 'r' in raw && 's' in raw) {
504
+ if (typeof raw.recovery !== 'number') {
505
+ throw new Error('signature missing recovery id');
506
+ }
507
+ recovery = raw.recovery & 0x01;
508
+ r = bigIntToBytes32(raw.r);
509
+ s = bigIntToBytes32(raw.s);
510
+ } else {
511
+ throw new Error(`unexpected signature shape (type=${raw?.constructor?.name})`);
512
+ }
513
+
514
+ const evmSig = new Uint8Array(65);
515
+ evmSig.set(r, 0);
516
+ evmSig.set(s, 32);
517
+ evmSig[64] = recovery;
518
+ return '0x' + bytesToHex(evmSig);
519
+ }
520
+
521
+ /** Big-endian 32-byte encoding of a bigint. */
522
+ function bigIntToBytes32(n) {
523
+ const out = new Uint8Array(32);
524
+ let x = BigInt(n);
525
+ for (let i = 31; i >= 0; i--) {
526
+ out[i] = Number(x & 0xffn);
527
+ x >>= 8n;
528
+ }
529
+ return out;
530
+ }
531
+
532
+ /**
533
+ * Sign an EIP-191 "personal_sign" message. Wraps the message with the
534
+ * `\x19Ethereum Signed Message:\n<length>` prefix, keccak256-hashes,
535
+ * then signs the digest. Returns a 65-byte sig with v bumped to 27/28
536
+ * per EIP-191 convention (compatible with Solidity's ecrecover).
537
+ *
538
+ * @param {Uint8Array} seed
539
+ * @param {string} chain
540
+ * @param {string|Uint8Array} message - UTF-8 string or raw bytes
541
+ * @returns {string}
542
+ */
543
+ export function signEip191Message(seed, chain, message) {
544
+ if (!isSecp256k1Chain(chain)) {
545
+ throw new Error(`chain ${chain} does not use secp256k1`);
546
+ }
547
+ const bytes = typeof message === 'string'
548
+ ? new TextEncoder().encode(message)
549
+ : message;
550
+ const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${bytes.length}`);
551
+ const payload = new Uint8Array(prefix.length + bytes.length);
552
+ payload.set(prefix, 0);
553
+ payload.set(bytes, prefix.length);
554
+ const digest = keccak_256(payload);
555
+ const sig = hexToBytes(signDigestSecp256k1(seed, chain, digest).slice(2));
556
+ sig[64] = sig[64] + 27; // EIP-191 uses 27/28 for v
557
+ return '0x' + bytesToHex(sig);
558
+ }
559
+
560
+ /**
561
+ * Sign arbitrary Solana transaction message bytes with the derived
562
+ * ed25519 key. Returns a 64-byte signature that the caller attaches
563
+ * to the `VersionedTransaction.signatures` array.
564
+ *
565
+ * @param {Uint8Array} seed
566
+ * @param {Uint8Array} messageBytes - The serialized Solana tx message
567
+ * @param {number} [index=0] - Derivation index
568
+ * @returns {string} 64-byte hex signature
569
+ */
570
+ export function signSolanaMessage(seed, messageBytes, index = 0) {
571
+ if (!(messageBytes instanceof Uint8Array)) {
572
+ throw new Error('messageBytes must be a Uint8Array');
573
+ }
574
+ const { privateKey } = deriveSOLAddress(seed, index);
575
+ const signature = ed25519.sign(messageBytes, privateKey);
576
+ return '0x' + bytesToHex(signature);
577
+ }
578
+
422
579
  /**
423
580
  * Derive address placeholder - actual address derivation happens on client
424
581
  * This returns the public key that the server can use to verify ownership
@@ -864,6 +1021,77 @@ export class WalletClient {
864
1021
  return broadcastResult;
865
1022
  }
866
1023
 
1024
+ // ==========================================================
1025
+ // Arbitrary-transaction signing (PR: arbitrary-tx-signing)
1026
+ //
1027
+ // These methods expose raw cryptographic signing so callers can
1028
+ // use CoinPay as a wallet provider for flows CoinPay itself
1029
+ // doesn't implement (Uniswap swaps, Jupiter routes, EIP-712
1030
+ // typed data, etc.). The caller builds the unsigned tx with
1031
+ // their own library (viem / ethers / @solana/web3.js), passes
1032
+ // the digest or message bytes to CoinPay, and receives the
1033
+ // signature back. CoinPay never learns the tx semantics — it
1034
+ // just produces cryptographic output.
1035
+ //
1036
+ // SECURITY: these methods are intentionally unprivileged from
1037
+ // CoinPay's perspective. If you expose them to untrusted
1038
+ // callers you're handing over signing authority for the wallet.
1039
+ // The CLI `wallet sign-digest` / `sign-message` commands
1040
+ // require the GPG-encrypted wallet to be unlocked, same as
1041
+ // `wallet send`.
1042
+ // ==========================================================
1043
+
1044
+ /**
1045
+ * Sign a 32-byte digest with the wallet's secp256k1 key for the
1046
+ * given EVM chain. Returns a 65-byte recoverable signature laid
1047
+ * out as `r(32) || s(32) || v(1)`, where v is the raw recovery
1048
+ * bit (0 or 1). Suitable for EIP-1559 / EIP-2930 typed txs.
1049
+ *
1050
+ * For legacy EIP-155 signatures the caller converts v to
1051
+ * `chainId * 2 + 35 + v`. For EIP-191 personal_sign, use
1052
+ * `signMessage()` below instead.
1053
+ *
1054
+ * @param {{chain: string, digest: string|Uint8Array}} params
1055
+ * @returns {string} 0x-prefixed 65-byte signature hex
1056
+ */
1057
+ signDigest({ chain, digest }) {
1058
+ if (!this.#seed) throw new Error('Wallet not initialized');
1059
+ const digestBytes = typeof digest === 'string'
1060
+ ? normalizeHexInput(digest, 'digest')
1061
+ : digest;
1062
+ return signDigestSecp256k1(this.#seed, chain, digestBytes);
1063
+ }
1064
+
1065
+ /**
1066
+ * Sign an EIP-191 "personal_sign" message with the wallet's
1067
+ * secp256k1 key. Handles the `\x19Ethereum Signed Message:\n`
1068
+ * prefix internally.
1069
+ *
1070
+ * @param {{chain: string, message: string|Uint8Array}} params
1071
+ * @returns {string} 0x-prefixed 65-byte signature hex
1072
+ */
1073
+ signMessage({ chain, message }) {
1074
+ if (!this.#seed) throw new Error('Wallet not initialized');
1075
+ return signEip191Message(this.#seed, chain, message);
1076
+ }
1077
+
1078
+ /**
1079
+ * Sign raw Solana transaction message bytes with the derived
1080
+ * ed25519 key. Returns a 64-byte signature the caller attaches
1081
+ * to their `VersionedTransaction.signatures` array.
1082
+ *
1083
+ * @param {{message: string|Uint8Array, index?: number}} params
1084
+ * `message` is either hex-encoded bytes or a raw Uint8Array.
1085
+ * @returns {string} 0x-prefixed 64-byte signature hex
1086
+ */
1087
+ signSolanaMessage({ message, index = 0 }) {
1088
+ if (!this.#seed) throw new Error('Wallet not initialized');
1089
+ const bytes = typeof message === 'string'
1090
+ ? normalizeHexInput(message, 'message')
1091
+ : message;
1092
+ return signSolanaMessage(this.#seed, bytes, index);
1093
+ }
1094
+
867
1095
  /**
868
1096
  * Get transaction history
869
1097
  * @param {Object} [options] - Query options
package/src/webhooks.js CHANGED
@@ -1,5 +1,19 @@
1
1
  /**
2
2
  * Webhook utilities for CoinPay SDK
3
+ *
4
+ * Contract:
5
+ * - Each business has ONE webhook URL + ONE webhook secret. Both crypto
6
+ * and card payment events route to the same endpoint, signed with the
7
+ * same secret. Distinguish rails via data.metadata.payment_rail
8
+ * ("crypto" or "card").
9
+ * - Header: X-CoinPay-Signature: t=<unix_seconds>,v1=<hex_hmac>
10
+ * - HMAC body: `${timestamp}.${rawBody}`
11
+ * - Algorithm: HMAC-SHA256
12
+ * - Tolerance: 300 seconds
13
+ *
14
+ * The server-side signer (src/lib/webhooks/service.ts) and the d0rz
15
+ * verifier (lib/coinpay-client.ts) are kept in lockstep with this file
16
+ * by src/lib/webhooks/contract.test.ts in the main repo. Don't drift.
3
17
  */
4
18
 
5
19
  import { createHmac, timingSafeEqual } from 'crypto';
@@ -44,7 +58,12 @@ export function verifyWebhookSignature({
44
58
  const signatureParts = {};
45
59
 
46
60
  for (const part of parts) {
47
- const [key, value] = part.split('=');
61
+ // Split on the FIRST '=' only. Splitting on every one truncates any value
62
+ // that legitimately contains '=' (base64 padding, for instance).
63
+ const separator = part.indexOf('=');
64
+ if (separator === -1) continue;
65
+ const key = part.slice(0, separator).trim();
66
+ const value = part.slice(separator + 1).trim();
48
67
  signatureParts[key] = value;
49
68
  }
50
69
 
@@ -55,8 +74,23 @@ export function verifyWebhookSignature({
55
74
  return false;
56
75
  }
57
76
 
58
- // Check timestamp tolerance
59
- const timestampAge = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
77
+ // Check timestamp tolerance.
78
+ //
79
+ // parseInt('abc', 10) is NaN, and every comparison against NaN is false —
80
+ // so `Math.abs(NaN) > tolerance` was false and the freshness check silently
81
+ // PASSED for any non-numeric timestamp. A security check that no-ops on
82
+ // malformed input is worse than no check, because it reads as one. Parse
83
+ // strictly and reject anything that is not a plain integer.
84
+ if (!/^\d+$/.test(String(timestamp))) {
85
+ return false;
86
+ }
87
+
88
+ const signedAt = Number(timestamp);
89
+ if (!Number.isSafeInteger(signedAt) || signedAt <= 0) {
90
+ return false;
91
+ }
92
+
93
+ const timestampAge = Math.floor(Date.now() / 1000) - signedAt;
60
94
  if (Math.abs(timestampAge) > tolerance) {
61
95
  return false;
62
96
  }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Browser-side x402 payer.
3
+ *
4
+ * Turns a 402 response into a paid retry, using whatever wallet the visitor
5
+ * actually has. CoinPay Wallet is preferred when present; otherwise any
6
+ * injected EIP-1193 wallet — MetaMask, Phantom's EVM provider, Rabby, Coinbase
7
+ * Wallet — can pay, because what gets signed is the standard EIP-3009
8
+ * authorization rather than anything CoinPay-specific.
9
+ *
10
+ * That interoperability is the whole point of signing the real structure. A
11
+ * bespoke payload would still be signable by MetaMask, but the resulting
12
+ * signature would authorise no transfer on-chain and only CoinPayPortal could
13
+ * interpret it — which is not a wallet integration, it is a lock-in.
14
+ *
15
+ * Usage:
16
+ * const res = await fetchWithX402('https://api.example.com/premium');
17
+ *
18
+ * @module x402-browser
19
+ */
20
+
21
+ import {
22
+ buildAuthorization,
23
+ buildEip3009Domain,
24
+ buildExactEvmPayment,
25
+ encodePaymentHeader,
26
+ evmChainId,
27
+ requiredAmount,
28
+ selectAcceptEntry,
29
+ toCaip2,
30
+ TRANSFER_WITH_AUTHORIZATION_TYPES,
31
+ } from './x402-v2.js';
32
+
33
+ /** How long a discovery sweep waits for EIP-6963 wallets to announce. */
34
+ const EIP6963_ANNOUNCE_MS = 100;
35
+
36
+ /**
37
+ * Enumerate injected EVM wallets via EIP-6963.
38
+ *
39
+ * Before EIP-6963 every wallet raced to own `window.ethereum`, so with two
40
+ * installed you got whichever won — the user could not choose, and a site
41
+ * could not tell which it had. EIP-6963 has each wallet announce itself with
42
+ * its own provider object instead.
43
+ *
44
+ * `window.ethereum` is still included as a last resort, for wallets that have
45
+ * not adopted the standard.
46
+ */
47
+ export async function discoverEvmWallets({ timeoutMs = EIP6963_ANNOUNCE_MS } = {}) {
48
+ const found = new Map();
49
+
50
+ if (typeof window === 'undefined') return [];
51
+
52
+ const onAnnounce = (event) => {
53
+ const detail = event.detail;
54
+ if (!detail?.info?.uuid || !detail.provider) return;
55
+ found.set(detail.info.uuid, { info: detail.info, provider: detail.provider });
56
+ };
57
+
58
+ window.addEventListener('eip6963:announceProvider', onAnnounce);
59
+ window.dispatchEvent(new Event('eip6963:requestProvider'));
60
+ await new Promise((resolve) => setTimeout(resolve, timeoutMs));
61
+ window.removeEventListener('eip6963:announceProvider', onAnnounce);
62
+
63
+ const wallets = [...found.values()].map(({ info, provider }) => ({
64
+ id: info.rdns || info.uuid,
65
+ name: info.name,
66
+ icon: info.icon,
67
+ provider,
68
+ }));
69
+
70
+ // Legacy fallback: only if nothing announced, so a 6963-aware wallet is not
71
+ // listed twice under two different identities.
72
+ if (wallets.length === 0 && window.ethereum) {
73
+ wallets.push({ id: 'injected', name: detectLegacyName(window.ethereum), provider: window.ethereum });
74
+ }
75
+
76
+ return wallets;
77
+ }
78
+
79
+ function detectLegacyName(provider) {
80
+ if (provider.isMetaMask) return 'MetaMask';
81
+ if (provider.isPhantom) return 'Phantom';
82
+ if (provider.isCoinbaseWallet) return 'Coinbase Wallet';
83
+ if (provider.isRabby) return 'Rabby';
84
+ return 'Injected Wallet';
85
+ }
86
+
87
+ /**
88
+ * True when a CoinPay Wallet that can actually pay an x402 invoice is present.
89
+ *
90
+ * Checks for the method, not merely for the wallet. `payX402` shipped after
91
+ * the provider itself, so an installed-but-older extension has `isCoinPay`
92
+ * and no way to pay — treating that as "CoinPay is here" would hand the
93
+ * payment to a wallet that cannot take it and throw a TypeError, when the
94
+ * visitor may well have MetaMask sitting right there.
95
+ */
96
+ export function hasCoinPayWallet() {
97
+ return (
98
+ typeof window !== 'undefined' &&
99
+ window.coinpay?.isCoinPay === true &&
100
+ typeof window.coinpay.payX402 === 'function'
101
+ );
102
+ }
103
+
104
+ /** True when CoinPay Wallet is installed but predates x402 support. */
105
+ export function hasOutdatedCoinPayWallet() {
106
+ return (
107
+ typeof window !== 'undefined' &&
108
+ window.coinpay?.isCoinPay === true &&
109
+ typeof window.coinpay.payX402 !== 'function'
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Choose a wallet to pay with.
115
+ *
116
+ * CoinPay Wallet first when installed — it is the only one that can pay on
117
+ * every rail CoinPayPortal accepts, including Bitcoin and Lightning, which no
118
+ * EVM wallet can touch. Otherwise the first injected EVM wallet, or one named
119
+ * explicitly by `preferWalletId`.
120
+ */
121
+ export async function selectWallet({ preferWalletId, allowCoinPay = true } = {}) {
122
+ if (allowCoinPay && hasCoinPayWallet() && !preferWalletId) {
123
+ return { kind: 'coinpay', id: 'coinpay', name: 'CoinPay Wallet', provider: window.coinpay };
124
+ }
125
+
126
+ const evm = await discoverEvmWallets();
127
+ if (evm.length === 0) {
128
+ if (allowCoinPay && hasCoinPayWallet()) {
129
+ return { kind: 'coinpay', id: 'coinpay', name: 'CoinPay Wallet', provider: window.coinpay };
130
+ }
131
+ return null;
132
+ }
133
+
134
+ const chosen = preferWalletId ? evm.find((w) => w.id === preferWalletId) : evm[0];
135
+ if (!chosen) return null;
136
+ return { kind: 'evm', ...chosen };
137
+ }
138
+
139
+ /** Ask an EIP-1193 provider for the active account, prompting if needed. */
140
+ async function requireEvmAccount(provider) {
141
+ const accounts = await provider.request({ method: 'eth_requestAccounts' });
142
+ if (!Array.isArray(accounts) || accounts.length === 0) {
143
+ throw new Error('Wallet returned no accounts');
144
+ }
145
+ return accounts[0];
146
+ }
147
+
148
+ /**
149
+ * Put an EIP-1193 wallet on the chain an `accepts` entry requires.
150
+ *
151
+ * Signing an EIP-712 payload whose domain names chain X while the wallet is on
152
+ * chain Y produces a signature that verifies against neither — wallets bind
153
+ * the domain's chainId to their active chain. So the switch is mandatory, not
154
+ * a nicety.
155
+ */
156
+ async function ensureEvmChain(provider, network) {
157
+ const chainId = evmChainId(network);
158
+ if (chainId === null) throw new Error(`Not an EVM network: ${network}`);
159
+ const hexChainId = `0x${chainId.toString(16)}`;
160
+
161
+ const current = await provider.request({ method: 'eth_chainId' });
162
+ if (typeof current === 'string' && current.toLowerCase() === hexChainId) return;
163
+
164
+ try {
165
+ await provider.request({
166
+ method: 'wallet_switchEthereumChain',
167
+ params: [{ chainId: hexChainId }],
168
+ });
169
+ } catch (err) {
170
+ // 4902 = chain unknown to the wallet. Adding it needs RPC details we do
171
+ // not have here, so surface it rather than guessing an RPC URL.
172
+ if (err?.code === 4902) {
173
+ throw new Error(
174
+ `Your wallet does not have chain ${chainId} configured. Add it, then try again.`,
175
+ );
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Sign an EIP-3009 authorization with an EIP-1193 wallet.
183
+ *
184
+ * @returns {{signature: string, authorization: object, from: string}}
185
+ */
186
+ export async function signExactEvm(provider, entry, { from } = {}) {
187
+ const payer = from ?? (await requireEvmAccount(provider));
188
+ await ensureEvmChain(provider, entry.network);
189
+
190
+ const domain = buildEip3009Domain({
191
+ network: entry.network,
192
+ asset: entry.asset,
193
+ name: entry.extra?.name,
194
+ version: entry.extra?.version,
195
+ });
196
+
197
+ const authorization = buildAuthorization({
198
+ from: payer,
199
+ to: entry.payTo,
200
+ value: requiredAmount(entry),
201
+ validForSeconds: entry.maxTimeoutSeconds ?? 600,
202
+ });
203
+
204
+ const typedData = {
205
+ types: {
206
+ EIP712Domain: [
207
+ { name: 'name', type: 'string' },
208
+ { name: 'version', type: 'string' },
209
+ { name: 'chainId', type: 'uint256' },
210
+ { name: 'verifyingContract', type: 'address' },
211
+ ],
212
+ ...TRANSFER_WITH_AUTHORIZATION_TYPES,
213
+ },
214
+ primaryType: 'TransferWithAuthorization',
215
+ domain,
216
+ message: authorization,
217
+ };
218
+
219
+ const signature = await provider.request({
220
+ method: 'eth_signTypedData_v4',
221
+ params: [payer, JSON.stringify(typedData)],
222
+ });
223
+
224
+ return { signature, authorization, from: payer };
225
+ }
226
+
227
+ /**
228
+ * Produce the `X-PAYMENT` header value for a 402 body.
229
+ *
230
+ * @param {object} paymentRequired the parsed 402 JSON (`{x402Version, accepts}`)
231
+ * @param {object} [options]
232
+ * @param {string} [options.preferWalletId]
233
+ * @param {string[]} [options.capabilities] override which networks to consider
234
+ */
235
+ export async function createPaymentHeader(paymentRequired, options = {}) {
236
+ const accepts = paymentRequired?.accepts;
237
+ if (!Array.isArray(accepts) || accepts.length === 0) {
238
+ throw new Error('402 response carried no `accepts` options');
239
+ }
240
+
241
+ const wallet = await selectWallet(options);
242
+ if (!wallet) {
243
+ if (hasOutdatedCoinPayWallet()) {
244
+ throw new Error(
245
+ 'Your CoinPay Wallet is too old to pay x402 invoices and no other wallet is ' +
246
+ 'available. Update it, or install MetaMask.',
247
+ );
248
+ }
249
+ throw new Error(
250
+ 'No wallet available to pay. Install CoinPay Wallet, MetaMask, or another EVM wallet.',
251
+ );
252
+ }
253
+
254
+ if (wallet.kind === 'coinpay') {
255
+ // The extension picks among the offered options itself — it knows which
256
+ // chains the user actually holds funds on, which this page does not.
257
+ const header = await wallet.provider.payX402(paymentRequired);
258
+ return { header, wallet };
259
+ }
260
+
261
+ // An injected EVM wallet can only pay the EVM entries.
262
+ const evmCapable = accepts
263
+ .map((entry) => entry.network)
264
+ .filter((network) => evmChainId(network) !== null);
265
+
266
+ const entry = selectAcceptEntry(accepts, options.capabilities ?? evmCapable);
267
+ if (!entry) {
268
+ throw new Error(
269
+ `${wallet.name} cannot pay any of the offered options ` +
270
+ `(${accepts.map((a) => toCaip2(a.network)).join(', ')}). ` +
271
+ 'CoinPay Wallet supports Bitcoin, Lightning and card rails as well.',
272
+ );
273
+ }
274
+
275
+ const { signature, authorization } = await signExactEvm(wallet.provider, entry);
276
+ const payment = buildExactEvmPayment({
277
+ network: entry.network,
278
+ signature,
279
+ authorization,
280
+ scheme: entry.scheme ?? 'exact',
281
+ });
282
+
283
+ return { header: encodePaymentHeader(payment), wallet, entry };
284
+ }
285
+
286
+ /**
287
+ * `fetch` that pays once if the resource asks for payment.
288
+ *
289
+ * Retries exactly one time. A second 402 after paying means the payment was
290
+ * rejected rather than missing, and retrying again would sign a fresh
291
+ * authorization for a resource that just refused one — at the user's expense.
292
+ *
293
+ * @param {string|URL|Request} input
294
+ * @param {RequestInit} [init]
295
+ * @param {object} [options] forwarded to {@link createPaymentHeader}
296
+ */
297
+ export async function fetchWithX402(input, init = {}, options = {}) {
298
+ const first = await fetch(input, init);
299
+ if (first.status !== 402) return first;
300
+
301
+ let paymentRequired;
302
+ try {
303
+ paymentRequired = await first.clone().json();
304
+ } catch {
305
+ throw new Error('Resource returned 402 but its body was not valid x402 JSON');
306
+ }
307
+
308
+ const { header } = await createPaymentHeader(paymentRequired, options);
309
+
310
+ const headers = new Headers(init.headers ?? {});
311
+ headers.set('X-PAYMENT', header);
312
+
313
+ const paid = await fetch(input, { ...init, headers });
314
+ if (paid.status === 402) {
315
+ throw new Error('Payment was rejected by the resource; not retrying to avoid double-paying');
316
+ }
317
+ return paid;
318
+ }