@profullstack/coinpay 0.6.11 → 0.9.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);
@@ -404,8 +407,17 @@ function getSecp256k1PublicKey(seed) {
404
407
  * @private
405
408
  */
406
409
  function signMessage(message, privateKey) {
407
- const messageHash = sha256(new TextEncoder().encode(message));
408
- const signature = secp256k1.sign(messageHash, privateKey);
410
+ // Signed over the RAW message bytes, not sha256(message).
411
+ //
412
+ // This used to hash first. The server verifies proofs by passing the raw
413
+ // encoded message straight to `secp256k1.verify` (see
414
+ // web-wallet/auth.ts::verifySecp256k1Signature), and noble treats that
415
+ // argument as the thing that was signed — so a signature over the digest
416
+ // verifies false against the message. Every proof of ownership this SDK
417
+ // produced was rejected, which meant `WalletClient.fromSeed()` could not
418
+ // import a wallet at all. Found while wiring the same proof into
419
+ // `create()` for V-04.
420
+ const signature = secp256k1.sign(new TextEncoder().encode(message), privateKey);
409
421
  // Handle different noble-curves versions:
410
422
  // v1.x returns Signature object with toCompactHex()
411
423
  // v2.x returns raw Uint8Array directly
@@ -419,6 +431,160 @@ function signMessage(message, privateKey) {
419
431
  return bytesToHex(signature.toCompactRawBytes());
420
432
  }
421
433
 
434
+ // ================================================================
435
+ // Arbitrary tx / message signing
436
+ // ----------------------------------------------------------------
437
+ // These primitives let callers (like b1dz.com's DEX engine) sign
438
+ // EVM contract calls and Solana transactions that CoinPay's built-in
439
+ // `send` flow doesn't cover (e.g., Uniswap swaps, Jupiter routes).
440
+ //
441
+ // The API intentionally stops at the cryptographic primitive:
442
+ // - EVM: caller builds the unsigned tx with their own library
443
+ // (viem / ethers / ethers-v5), hashes it per EIP-1559 or
444
+ // EIP-155, hands us the 32-byte digest, gets back a
445
+ // recoverable signature
446
+ // - Solana: caller builds the unsigned Transaction / VersionedMessage,
447
+ // hands us the raw message bytes, gets back an ed25519
448
+ // signature they can attach to the tx
449
+ //
450
+ // This keeps the crypto surface minimal and correct. We don't try to
451
+ // re-implement RLP, EIP-712, or Solana's transaction encoder inside
452
+ // CoinPay — those live in the caller's toolchain.
453
+ // ================================================================
454
+
455
+ /** Chains that sign via secp256k1 (32-byte digest → recoverable sig). */
456
+ function isSecp256k1Chain(chain) {
457
+ return SECP256K1_CHAINS.includes(chain);
458
+ }
459
+
460
+ /** Chains that sign via ed25519 (arbitrary message bytes → 64-byte sig). */
461
+ function isEd25519Chain(chain) {
462
+ return chain === 'SOL' || chain === 'USDC_SOL' || chain === 'USDT_SOL';
463
+ }
464
+
465
+ function normalizeHexInput(hex, label = 'hex') {
466
+ if (typeof hex !== 'string') throw new Error(`${label} must be a string`);
467
+ const stripped = hex.startsWith('0x') ? hex.slice(2) : hex;
468
+ if (stripped.length === 0) throw new Error(`${label} is empty`);
469
+ if (stripped.length % 2 !== 0) throw new Error(`${label} has odd length`);
470
+ if (!/^[0-9a-fA-F]+$/.test(stripped)) throw new Error(`${label} contains non-hex chars`);
471
+ return hexToBytes(stripped);
472
+ }
473
+
474
+ /**
475
+ * Sign a 32-byte digest with a secp256k1 chain's derived key.
476
+ * Returns a 65-byte recoverable signature formatted as `r(32) || s(32) || v(1)`
477
+ * where v is the low recovery id (0 or 1). This is the layout every
478
+ * EVM client (viem, ethers v6) expects for post-typed-transaction
479
+ * (EIP-1559 / EIP-2930) signatures.
480
+ *
481
+ * For legacy / EIP-155 transactions the caller is responsible for
482
+ * converting v to `chainId * 2 + 35 + recId` after receiving the sig.
483
+ * For EIP-191 (personal_sign) the caller adds 27.
484
+ *
485
+ * @param {Uint8Array} seed - Master seed
486
+ * @param {string} chain - BIP44 chain code (ETH, POL, BNB, ...)
487
+ * @param {Uint8Array} digest - Exactly 32 bytes
488
+ * @returns {string} `0x`-prefixed 65-byte hex signature
489
+ */
490
+ export function signDigestSecp256k1(seed, chain, digest) {
491
+ if (!isSecp256k1Chain(chain)) {
492
+ throw new Error(`chain ${chain} does not use secp256k1`);
493
+ }
494
+ if (!(digest instanceof Uint8Array) || digest.length !== 32) {
495
+ throw new Error('digest must be a 32-byte Uint8Array');
496
+ }
497
+ const { privateKey } = deriveKeyPair(seed, chain, 0);
498
+
499
+ // noble-curves has two incompatible signing APIs across major versions:
500
+ // v1.x: sign(hash, key) → Signature object with .r, .s, .recovery
501
+ // v2.x: sign(hash, key, { format: 'recovered' }) → 65-byte Uint8Array
502
+ // [recovery, r(32), s(32)]
503
+ // Detect at runtime so this works with whichever version is pinned.
504
+ // Requesting 'recovered' on v1 is harmless — v1 ignores the option
505
+ // and still returns a Signature object.
506
+ const raw = secp256k1.sign(digest, privateKey, { format: 'recovered' });
507
+ let r, s, recovery;
508
+ if (raw instanceof Uint8Array && raw.length === 65) {
509
+ recovery = raw[0] & 0x01;
510
+ r = raw.subarray(1, 33);
511
+ s = raw.subarray(33, 65);
512
+ } else if (raw && typeof raw === 'object' && 'r' in raw && 's' in raw) {
513
+ if (typeof raw.recovery !== 'number') {
514
+ throw new Error('signature missing recovery id');
515
+ }
516
+ recovery = raw.recovery & 0x01;
517
+ r = bigIntToBytes32(raw.r);
518
+ s = bigIntToBytes32(raw.s);
519
+ } else {
520
+ throw new Error(`unexpected signature shape (type=${raw?.constructor?.name})`);
521
+ }
522
+
523
+ const evmSig = new Uint8Array(65);
524
+ evmSig.set(r, 0);
525
+ evmSig.set(s, 32);
526
+ evmSig[64] = recovery;
527
+ return '0x' + bytesToHex(evmSig);
528
+ }
529
+
530
+ /** Big-endian 32-byte encoding of a bigint. */
531
+ function bigIntToBytes32(n) {
532
+ const out = new Uint8Array(32);
533
+ let x = BigInt(n);
534
+ for (let i = 31; i >= 0; i--) {
535
+ out[i] = Number(x & 0xffn);
536
+ x >>= 8n;
537
+ }
538
+ return out;
539
+ }
540
+
541
+ /**
542
+ * Sign an EIP-191 "personal_sign" message. Wraps the message with the
543
+ * `\x19Ethereum Signed Message:\n<length>` prefix, keccak256-hashes,
544
+ * then signs the digest. Returns a 65-byte sig with v bumped to 27/28
545
+ * per EIP-191 convention (compatible with Solidity's ecrecover).
546
+ *
547
+ * @param {Uint8Array} seed
548
+ * @param {string} chain
549
+ * @param {string|Uint8Array} message - UTF-8 string or raw bytes
550
+ * @returns {string}
551
+ */
552
+ export function signEip191Message(seed, chain, message) {
553
+ if (!isSecp256k1Chain(chain)) {
554
+ throw new Error(`chain ${chain} does not use secp256k1`);
555
+ }
556
+ const bytes = typeof message === 'string'
557
+ ? new TextEncoder().encode(message)
558
+ : message;
559
+ const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${bytes.length}`);
560
+ const payload = new Uint8Array(prefix.length + bytes.length);
561
+ payload.set(prefix, 0);
562
+ payload.set(bytes, prefix.length);
563
+ const digest = keccak_256(payload);
564
+ const sig = hexToBytes(signDigestSecp256k1(seed, chain, digest).slice(2));
565
+ sig[64] = sig[64] + 27; // EIP-191 uses 27/28 for v
566
+ return '0x' + bytesToHex(sig);
567
+ }
568
+
569
+ /**
570
+ * Sign arbitrary Solana transaction message bytes with the derived
571
+ * ed25519 key. Returns a 64-byte signature that the caller attaches
572
+ * to the `VersionedTransaction.signatures` array.
573
+ *
574
+ * @param {Uint8Array} seed
575
+ * @param {Uint8Array} messageBytes - The serialized Solana tx message
576
+ * @param {number} [index=0] - Derivation index
577
+ * @returns {string} 64-byte hex signature
578
+ */
579
+ export function signSolanaMessage(seed, messageBytes, index = 0) {
580
+ if (!(messageBytes instanceof Uint8Array)) {
581
+ throw new Error('messageBytes must be a Uint8Array');
582
+ }
583
+ const { privateKey } = deriveSOLAddress(seed, index);
584
+ const signature = ed25519.sign(messageBytes, privateKey);
585
+ return '0x' + bytesToHex(signature);
586
+ }
587
+
422
588
  /**
423
589
  * Derive address placeholder - actual address derivation happens on client
424
590
  * This returns the public key that the server can use to verify ownership
@@ -515,11 +681,24 @@ export class WalletClient {
515
681
  };
516
682
  });
517
683
 
684
+ // V-04: /create accepted a public key and a list of on-chain addresses
685
+ // from anyone, with no proof that either was theirs, while /import required
686
+ // a signature for the same write. `wallet_addresses` is globally unique on
687
+ // (address, chain), so an unproved registration permanently denies an
688
+ // address to whoever actually owns it.
689
+ const proofMessage = `CoinPay Wallet Create: ${Date.now()}`;
690
+ const { privateKey: proofKey } = deriveKeyPair(seed, 'ETH', 0);
691
+ const proofSignature = signMessage(proofMessage, proofKey);
692
+
518
693
  const result = await client.#request('/web-wallet/create', {
519
694
  method: 'POST',
520
695
  body: JSON.stringify({
521
696
  public_key_secp256k1: publicKeySecp256k1,
522
697
  initial_addresses: initialAddresses,
698
+ proof_of_ownership: {
699
+ message: proofMessage,
700
+ signature: proofSignature,
701
+ },
523
702
  }),
524
703
  });
525
704
 
@@ -840,30 +1019,109 @@ export class WalletClient {
840
1019
  }),
841
1020
  });
842
1021
 
843
- // Step 2: Sign the transaction locally
844
- // Note: This is a simplified version - real implementation would need
845
- // chain-specific signing logic
846
- const { privateKey } = deriveKeyPair(this.#seed, chain, 0);
847
- const unsignedTx = prepareResult.unsigned_tx;
848
-
849
- // For EVM chains, sign the transaction hash
850
- // For BTC, sign each input
851
- // This is simplified - real implementation needs chain-specific logic
852
- const signedTx = signMessage(unsignedTx, privateKey);
853
-
854
- // Step 3: Broadcast the signed transaction
855
- const broadcastResult = await this.#request(`/web-wallet/${this.#walletId}/broadcast`, {
856
- method: 'POST',
857
- body: JSON.stringify({
858
- tx_id: prepareResult.tx_id,
859
- signed_tx: signedTx,
860
- chain,
861
- }),
862
- });
863
-
864
- return broadcastResult;
1022
+ // Step 2: Sign the transaction locally.
1023
+ //
1024
+ // NOT IMPLEMENTED, and it now says so instead of pretending.
1025
+ //
1026
+ // This used to call `signMessage(unsignedTx, privateKey)` — a generic
1027
+ // message signature over the JSON of the unsigned transaction — and post
1028
+ // the result as `signed_tx`. That is not a signed transaction on any chain:
1029
+ // a real one is the RLP-encoded (EVM), PSBT-finalised (BTC) or
1030
+ // wire-serialised (Solana) transaction carrying the signature in the right
1031
+ // field. What was produced could never be broadcast, so this method failed
1032
+ // for every integrator who called it — silently, by producing plausible
1033
+ // output that only failed later at the node.
1034
+ //
1035
+ // Failing here rather than guessing is deliberate. Serialising each chain
1036
+ // correctly needs the exact shape of `prepareResult.unsigned_tx` (defined by
1037
+ // the server, not this package) and verification against a live node.
1038
+ // Shipping an unverified serialiser would reintroduce the same class of bug
1039
+ // with more code behind it.
1040
+ //
1041
+ // The primitives to do this yourself are exported and do work:
1042
+ // `signDigest()` for EVM digests and `signMessage()` for EIP-191. Build the
1043
+ // transaction with viem / ethers / @solana/web3.js, sign the digest here,
1044
+ // and post the serialised result to the broadcast endpoint.
1045
+ void prepareResult;
1046
+ throw new Error(
1047
+ 'WalletClient.send() is not implemented: chain-specific transaction ' +
1048
+ 'serialisation is not available in this package. Build and serialise the ' +
1049
+ 'transaction with a chain library, sign the digest with signDigest(), and ' +
1050
+ 'POST it to /web-wallet/:id/broadcast. See docs/sdk for an example.'
1051
+ );
865
1052
  }
866
1053
 
1054
+ // ==========================================================
1055
+ // Arbitrary-transaction signing (PR: arbitrary-tx-signing)
1056
+ //
1057
+ // These methods expose raw cryptographic signing so callers can
1058
+ // use CoinPay as a wallet provider for flows CoinPay itself
1059
+ // doesn't implement (Uniswap swaps, Jupiter routes, EIP-712
1060
+ // typed data, etc.). The caller builds the unsigned tx with
1061
+ // their own library (viem / ethers / @solana/web3.js), passes
1062
+ // the digest or message bytes to CoinPay, and receives the
1063
+ // signature back. CoinPay never learns the tx semantics — it
1064
+ // just produces cryptographic output.
1065
+ //
1066
+ // SECURITY: these methods are intentionally unprivileged from
1067
+ // CoinPay's perspective. If you expose them to untrusted
1068
+ // callers you're handing over signing authority for the wallet.
1069
+ // The CLI `wallet sign-digest` / `sign-message` commands
1070
+ // require the GPG-encrypted wallet to be unlocked, same as
1071
+ // `wallet send`.
1072
+ // ==========================================================
1073
+
1074
+ /**
1075
+ * Sign a 32-byte digest with the wallet's secp256k1 key for the
1076
+ * given EVM chain. Returns a 65-byte recoverable signature laid
1077
+ * out as `r(32) || s(32) || v(1)`, where v is the raw recovery
1078
+ * bit (0 or 1). Suitable for EIP-1559 / EIP-2930 typed txs.
1079
+ *
1080
+ * For legacy EIP-155 signatures the caller converts v to
1081
+ * `chainId * 2 + 35 + v`. For EIP-191 personal_sign, use
1082
+ * `signMessage()` below instead.
1083
+ *
1084
+ * @param {{chain: string, digest: string|Uint8Array}} params
1085
+ * @returns {string} 0x-prefixed 65-byte signature hex
1086
+ */
1087
+ signDigest({ chain, digest }) {
1088
+ if (!this.#seed) throw new Error('Wallet not initialized');
1089
+ const digestBytes = typeof digest === 'string'
1090
+ ? normalizeHexInput(digest, 'digest')
1091
+ : digest;
1092
+ return signDigestSecp256k1(this.#seed, chain, digestBytes);
1093
+ }
1094
+
1095
+ /**
1096
+ * Sign an EIP-191 "personal_sign" message with the wallet's
1097
+ * secp256k1 key. Handles the `\x19Ethereum Signed Message:\n`
1098
+ * prefix internally.
1099
+ *
1100
+ * @param {{chain: string, message: string|Uint8Array}} params
1101
+ * @returns {string} 0x-prefixed 65-byte signature hex
1102
+ */
1103
+ signMessage({ chain, message }) {
1104
+ if (!this.#seed) throw new Error('Wallet not initialized');
1105
+ return signEip191Message(this.#seed, chain, message);
1106
+ }
1107
+
1108
+ /**
1109
+ * Sign raw Solana transaction message bytes with the derived
1110
+ * ed25519 key. Returns a 64-byte signature the caller attaches
1111
+ * to their `VersionedTransaction.signatures` array.
1112
+ *
1113
+ * @param {{message: string|Uint8Array, index?: number}} params
1114
+ * `message` is either hex-encoded bytes or a raw Uint8Array.
1115
+ * @returns {string} 0x-prefixed 64-byte signature hex
1116
+ */
1117
+ signSolanaMessage({ message, index = 0 }) {
1118
+ if (!this.#seed) throw new Error('Wallet not initialized');
1119
+ const bytes = typeof message === 'string'
1120
+ ? normalizeHexInput(message, 'message')
1121
+ : message;
1122
+ return signSolanaMessage(this.#seed, bytes, index);
1123
+ }
1124
+
867
1125
  /**
868
1126
  * Get transaction history
869
1127
  * @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
  }