@profullstack/coinpay 0.6.11 → 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/tokens.js ADDED
@@ -0,0 +1,34 @@
1
+ import { CoinPayClient } from './client.js';
2
+
3
+ /**
4
+ * List checkout tokens for a business.
5
+ * @param {Object} params
6
+ * @param {string} [params.apiKey] - API key if not using client
7
+ * @param {CoinPayClient} [params.client] - Existing client
8
+ * @param {string} [params.businessId] - Business ID
9
+ * @param {boolean} [params.activeOnly] - Only return active wallets
10
+ * @returns {Promise<Object>} Tokens response
11
+ */
12
+ export async function getTokens({ apiKey, client, businessId, activeOnly } = {}) {
13
+ const coinpay = client || new CoinPayClient({ apiKey });
14
+ return coinpay.getTokens({ businessId, activeOnly });
15
+ }
16
+
17
+ /**
18
+ * List supported coins for a business.
19
+ * @param {Object} params
20
+ * @param {string} [params.apiKey] - API key if not using client
21
+ * @param {CoinPayClient} [params.client] - Existing client
22
+ * @param {string} [params.businessId] - Business ID
23
+ * @param {boolean} [params.activeOnly] - Only return active wallets
24
+ * @returns {Promise<Object>} Supported coins response
25
+ */
26
+ export async function getSupportedCoins({ apiKey, client, businessId, activeOnly } = {}) {
27
+ const coinpay = client || new CoinPayClient({ apiKey });
28
+ return coinpay.getSupportedCoins({ businessId, activeOnly });
29
+ }
30
+
31
+ export default {
32
+ getTokens,
33
+ getSupportedCoins,
34
+ };
package/src/wallet.d.ts CHANGED
@@ -251,6 +251,17 @@ export declare function validateMnemonic(mnemonic: string): boolean;
251
251
  */
252
252
  export declare function getDerivationPath(chain: string, index?: number): string;
253
253
 
254
+ /**
255
+ * Derive the address for a chain from a raw BIP-39 seed.
256
+ * (Standalone counterpart to `WalletClient.deriveAddress`; exported from
257
+ * wallet.js but previously missing from these type declarations.)
258
+ *
259
+ * @param seed - Raw seed bytes, e.g. bip39.mnemonicToSeedSync(mnemonic)
260
+ * @param chain - Chain id (e.g. 'BTC', 'ETH', 'SOL', 'POL', 'BCH')
261
+ * @param index - Address index (default 0)
262
+ */
263
+ export declare function deriveAddress(seed: Uint8Array, chain: string, index?: number): string;
264
+
254
265
  /**
255
266
  * Restore a seed from encrypted backup
256
267
  */
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
  }