@wopr-network/platform-core 1.18.0 → 1.20.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/dist/api/routes/admin-audit-helper.d.ts +1 -1
- package/dist/billing/crypto/btc/__tests__/address-gen.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/address-gen.test.js +44 -0
- package/dist/billing/crypto/btc/__tests__/config.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/config.test.js +24 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +92 -0
- package/dist/billing/crypto/btc/address-gen.d.ts +8 -0
- package/dist/billing/crypto/btc/address-gen.js +34 -0
- package/dist/billing/crypto/btc/checkout.d.ts +21 -0
- package/dist/billing/crypto/btc/checkout.js +42 -0
- package/dist/billing/crypto/btc/config.d.ts +12 -0
- package/dist/billing/crypto/btc/config.js +28 -0
- package/dist/billing/crypto/btc/index.d.ts +9 -0
- package/dist/billing/crypto/btc/index.js +5 -0
- package/dist/billing/crypto/btc/settler.d.ts +23 -0
- package/dist/billing/crypto/btc/settler.js +55 -0
- package/dist/billing/crypto/btc/types.d.ts +23 -0
- package/dist/billing/crypto/btc/types.js +1 -0
- package/dist/billing/crypto/btc/watcher.d.ts +28 -0
- package/dist/billing/crypto/btc/watcher.js +83 -0
- package/dist/billing/crypto/charge-store.d.ts +3 -3
- package/dist/billing/crypto/evm/__tests__/config.test.js +42 -2
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +31 -17
- package/dist/billing/crypto/evm/checkout.js +4 -2
- package/dist/billing/crypto/evm/config.js +73 -0
- package/dist/billing/crypto/evm/types.d.ts +1 -1
- package/dist/billing/crypto/evm/watcher.js +2 -0
- package/dist/billing/crypto/index.d.ts +2 -1
- package/dist/billing/crypto/index.js +1 -0
- package/dist/db/schema/crypto.js +1 -1
- package/dist/fleet/__tests__/rollout-strategy.test.d.ts +1 -0
- package/dist/fleet/__tests__/rollout-strategy.test.js +157 -0
- package/dist/fleet/__tests__/volume-snapshot-manager.test.d.ts +1 -0
- package/dist/fleet/__tests__/volume-snapshot-manager.test.js +171 -0
- package/dist/fleet/index.d.ts +2 -0
- package/dist/fleet/index.js +2 -0
- package/dist/fleet/rollout-strategy.d.ts +52 -0
- package/dist/fleet/rollout-strategy.js +91 -0
- package/dist/fleet/volume-snapshot-manager.d.ts +35 -0
- package/dist/fleet/volume-snapshot-manager.js +185 -0
- package/package.json +3 -1
- package/src/api/routes/admin-audit-helper.ts +1 -1
- package/src/billing/crypto/btc/__tests__/address-gen.test.ts +53 -0
- package/src/billing/crypto/btc/__tests__/config.test.ts +28 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +103 -0
- package/src/billing/crypto/btc/address-gen.ts +41 -0
- package/src/billing/crypto/btc/checkout.ts +61 -0
- package/src/billing/crypto/btc/config.ts +33 -0
- package/src/billing/crypto/btc/index.ts +9 -0
- package/src/billing/crypto/btc/settler.ts +74 -0
- package/src/billing/crypto/btc/types.ts +25 -0
- package/src/billing/crypto/btc/watcher.ts +115 -0
- package/src/billing/crypto/charge-store.ts +3 -3
- package/src/billing/crypto/evm/__tests__/config.test.ts +51 -2
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +34 -17
- package/src/billing/crypto/evm/checkout.ts +4 -2
- package/src/billing/crypto/evm/config.ts +73 -0
- package/src/billing/crypto/evm/types.ts +1 -1
- package/src/billing/crypto/evm/watcher.ts +2 -0
- package/src/billing/crypto/index.ts +2 -1
- package/src/db/schema/crypto.ts +1 -1
- package/src/fleet/__tests__/rollout-strategy.test.ts +192 -0
- package/src/fleet/__tests__/volume-snapshot-manager.test.ts +218 -0
- package/src/fleet/index.ts +2 -0
- package/src/fleet/rollout-strategy.ts +128 -0
- package/src/fleet/volume-snapshot-manager.ts +213 -0
- package/src/marketplace/volume-installer.test.ts +8 -2
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Credit } from "../../../credits/credit.js";
|
|
2
|
+
import type { ILedger } from "../../../credits/ledger.js";
|
|
3
|
+
import type { ICryptoChargeRepository } from "../charge-store.js";
|
|
4
|
+
import type { CryptoWebhookResult } from "../types.js";
|
|
5
|
+
import type { BtcPaymentEvent } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface BtcSettlerDeps {
|
|
8
|
+
chargeStore: Pick<ICryptoChargeRepository, "getByDepositAddress" | "updateStatus" | "markCredited">;
|
|
9
|
+
creditLedger: Pick<ILedger, "credit" | "hasReferenceId">;
|
|
10
|
+
onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Settle a BTC payment — look up charge by deposit address, credit ledger.
|
|
15
|
+
*
|
|
16
|
+
* Same idempotency pattern as EVM settler and BTCPay webhook handler:
|
|
17
|
+
* 1. Charge-level: check creditedAt (prevents second tx double-credit)
|
|
18
|
+
* 2. Transfer-level: creditLedger.hasReferenceId (atomic)
|
|
19
|
+
* 3. Advisory: chargeStore.markCredited
|
|
20
|
+
*
|
|
21
|
+
* Credits the CHARGE amount (not the BTC amount) for consistency.
|
|
22
|
+
*
|
|
23
|
+
* CRITICAL: charge.amountUsdCents is in USD cents (integer).
|
|
24
|
+
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
25
|
+
*/
|
|
26
|
+
export async function settleBtcPayment(deps: BtcSettlerDeps, event: BtcPaymentEvent): Promise<CryptoWebhookResult> {
|
|
27
|
+
const { chargeStore, creditLedger } = deps;
|
|
28
|
+
|
|
29
|
+
const charge = await chargeStore.getByDepositAddress(event.address);
|
|
30
|
+
if (!charge) {
|
|
31
|
+
return { handled: false, status: "Settled" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await chargeStore.updateStatus(charge.referenceId, "Settled");
|
|
35
|
+
|
|
36
|
+
// Charge-level idempotency
|
|
37
|
+
if (charge.creditedAt != null) {
|
|
38
|
+
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Transfer-level idempotency
|
|
42
|
+
const creditRef = `btc:${event.txid}`;
|
|
43
|
+
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
44
|
+
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Underpayment check
|
|
48
|
+
if (event.amountUsdCents < charge.amountUsdCents) {
|
|
49
|
+
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const creditCents = charge.amountUsdCents;
|
|
53
|
+
await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
|
|
54
|
+
description: `BTC credit purchase (txid: ${event.txid})`,
|
|
55
|
+
referenceId: creditRef,
|
|
56
|
+
fundingSource: "crypto",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await chargeStore.markCredited(charge.referenceId);
|
|
60
|
+
|
|
61
|
+
let reactivatedBots: string[] | undefined;
|
|
62
|
+
if (deps.onCreditsPurchased) {
|
|
63
|
+
reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger as ILedger);
|
|
64
|
+
if (reactivatedBots.length === 0) reactivatedBots = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
handled: true,
|
|
69
|
+
status: "Settled",
|
|
70
|
+
tenant: charge.tenantId,
|
|
71
|
+
creditedCents: creditCents,
|
|
72
|
+
reactivatedBots,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** BTC payment event emitted when a deposit is confirmed. */
|
|
2
|
+
export interface BtcPaymentEvent {
|
|
3
|
+
readonly address: string;
|
|
4
|
+
readonly txid: string;
|
|
5
|
+
/** Amount in satoshis (integer). */
|
|
6
|
+
readonly amountSats: number;
|
|
7
|
+
/** USD cents equivalent (integer). */
|
|
8
|
+
readonly amountUsdCents: number;
|
|
9
|
+
readonly confirmations: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Options for creating a BTC checkout. */
|
|
13
|
+
export interface BtcCheckoutOpts {
|
|
14
|
+
tenant: string;
|
|
15
|
+
amountUsd: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Bitcoind RPC configuration. */
|
|
19
|
+
export interface BitcoindConfig {
|
|
20
|
+
readonly rpcUrl: string;
|
|
21
|
+
readonly rpcUser: string;
|
|
22
|
+
readonly rpcPassword: string;
|
|
23
|
+
readonly network: "mainnet" | "testnet" | "regtest";
|
|
24
|
+
readonly confirmations: number;
|
|
25
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
|
|
2
|
+
|
|
3
|
+
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
4
|
+
|
|
5
|
+
export interface BtcWatcherOpts {
|
|
6
|
+
config: BitcoindConfig;
|
|
7
|
+
rpcCall: RpcCall;
|
|
8
|
+
/** Addresses to watch (must be imported into bitcoind wallet first). */
|
|
9
|
+
watchedAddresses: string[];
|
|
10
|
+
onPayment: (event: BtcPaymentEvent) => void | Promise<void>;
|
|
11
|
+
/** Current BTC/USD price for conversion. */
|
|
12
|
+
getBtcPrice: () => Promise<number>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReceivedByAddress {
|
|
16
|
+
address: string;
|
|
17
|
+
amount: number;
|
|
18
|
+
confirmations: number;
|
|
19
|
+
txids: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Track which txids we've already processed to avoid double-crediting. */
|
|
23
|
+
const processedTxids = new Set<string>();
|
|
24
|
+
|
|
25
|
+
export class BtcWatcher {
|
|
26
|
+
private readonly rpc: RpcCall;
|
|
27
|
+
private readonly addresses: Set<string>;
|
|
28
|
+
private readonly onPayment: BtcWatcherOpts["onPayment"];
|
|
29
|
+
private readonly minConfirmations: number;
|
|
30
|
+
private readonly getBtcPrice: () => Promise<number>;
|
|
31
|
+
|
|
32
|
+
constructor(opts: BtcWatcherOpts) {
|
|
33
|
+
this.rpc = opts.rpcCall;
|
|
34
|
+
this.addresses = new Set(opts.watchedAddresses);
|
|
35
|
+
this.onPayment = opts.onPayment;
|
|
36
|
+
this.minConfirmations = opts.config.confirmations;
|
|
37
|
+
this.getBtcPrice = opts.getBtcPrice;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Update the set of watched addresses. */
|
|
41
|
+
setWatchedAddresses(addresses: string[]): void {
|
|
42
|
+
this.addresses.clear();
|
|
43
|
+
for (const a of addresses) this.addresses.add(a);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Import an address into bitcoind's wallet (watch-only, no rescan). */
|
|
47
|
+
async importAddress(address: string): Promise<void> {
|
|
48
|
+
await this.rpc("importaddress", [address, "", false]);
|
|
49
|
+
this.addresses.add(address);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Poll for confirmed payments to watched addresses. */
|
|
53
|
+
async poll(): Promise<void> {
|
|
54
|
+
if (this.addresses.size === 0) return;
|
|
55
|
+
|
|
56
|
+
const received = (await this.rpc("listreceivedbyaddress", [
|
|
57
|
+
this.minConfirmations,
|
|
58
|
+
false, // include_empty
|
|
59
|
+
true, // include_watchonly
|
|
60
|
+
])) as ReceivedByAddress[];
|
|
61
|
+
|
|
62
|
+
const btcPrice = await this.getBtcPrice();
|
|
63
|
+
|
|
64
|
+
for (const entry of received) {
|
|
65
|
+
if (!this.addresses.has(entry.address)) continue;
|
|
66
|
+
|
|
67
|
+
for (const txid of entry.txids) {
|
|
68
|
+
if (processedTxids.has(txid)) continue;
|
|
69
|
+
processedTxids.add(txid);
|
|
70
|
+
|
|
71
|
+
// Get transaction details for the exact amount sent to this address
|
|
72
|
+
const tx = (await this.rpc("gettransaction", [txid, true])) as {
|
|
73
|
+
details: Array<{ address: string; amount: number; category: string }>;
|
|
74
|
+
confirmations: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
|
|
78
|
+
if (!detail) continue;
|
|
79
|
+
|
|
80
|
+
const amountSats = Math.round(detail.amount * 100_000_000);
|
|
81
|
+
const amountUsdCents = Math.round(detail.amount * btcPrice * 100);
|
|
82
|
+
|
|
83
|
+
const event: BtcPaymentEvent = {
|
|
84
|
+
address: entry.address,
|
|
85
|
+
txid,
|
|
86
|
+
amountSats,
|
|
87
|
+
amountUsdCents,
|
|
88
|
+
confirmations: tx.confirmations,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
await this.onPayment(event);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Create a bitcoind JSON-RPC caller with basic auth. */
|
|
98
|
+
export function createBitcoindRpc(config: BitcoindConfig): RpcCall {
|
|
99
|
+
let id = 0;
|
|
100
|
+
const auth = btoa(`${config.rpcUser}:${config.rpcPassword}`);
|
|
101
|
+
return async (method: string, params: unknown[]): Promise<unknown> => {
|
|
102
|
+
const res = await fetch(config.rpcUrl, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: {
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
Authorization: `Basic ${auth}`,
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({ jsonrpc: "1.0", id: ++id, method, params }),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) throw new Error(`bitcoind ${method} failed: ${res.status}`);
|
|
111
|
+
const data = (await res.json()) as { result?: unknown; error?: { message: string } };
|
|
112
|
+
if (data.error) throw new Error(`bitcoind ${method}: ${data.error.message}`);
|
|
113
|
+
return data.result;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -19,7 +19,7 @@ export interface CryptoChargeRecord {
|
|
|
19
19
|
derivationIndex: number | null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export interface
|
|
22
|
+
export interface CryptoDepositChargeInput {
|
|
23
23
|
referenceId: string;
|
|
24
24
|
tenantId: string;
|
|
25
25
|
amountUsdCents: number;
|
|
@@ -40,7 +40,7 @@ export interface ICryptoChargeRepository {
|
|
|
40
40
|
): Promise<void>;
|
|
41
41
|
markCredited(referenceId: string): Promise<void>;
|
|
42
42
|
isCredited(referenceId: string): Promise<boolean>;
|
|
43
|
-
createStablecoinCharge(input:
|
|
43
|
+
createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
|
|
44
44
|
getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
|
|
45
45
|
getNextDerivationIndex(): Promise<number>;
|
|
46
46
|
}
|
|
@@ -134,7 +134,7 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/** Create a stablecoin charge with chain/token/deposit address. */
|
|
137
|
-
async createStablecoinCharge(input:
|
|
137
|
+
async createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void> {
|
|
138
138
|
await this.db.insert(cryptoCharges).values({
|
|
139
139
|
referenceId: input.referenceId,
|
|
140
140
|
tenantId: input.tenantId,
|
|
@@ -9,6 +9,24 @@ describe("getChainConfig", () => {
|
|
|
9
9
|
expect(cfg.blockTimeMs).toBe(2000);
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
+
it("returns Ethereum config", () => {
|
|
13
|
+
const cfg = getChainConfig("ethereum");
|
|
14
|
+
expect(cfg.chainId).toBe(1);
|
|
15
|
+
expect(cfg.confirmations).toBe(12);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns Arbitrum config", () => {
|
|
19
|
+
const cfg = getChainConfig("arbitrum");
|
|
20
|
+
expect(cfg.chainId).toBe(42161);
|
|
21
|
+
expect(cfg.confirmations).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns Polygon config", () => {
|
|
25
|
+
const cfg = getChainConfig("polygon");
|
|
26
|
+
expect(cfg.chainId).toBe(137);
|
|
27
|
+
expect(cfg.confirmations).toBe(32);
|
|
28
|
+
});
|
|
29
|
+
|
|
12
30
|
it("throws on unknown chain", () => {
|
|
13
31
|
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
14
32
|
expect(() => getChainConfig("solana" as any)).toThrow("Unsupported chain");
|
|
@@ -35,9 +53,40 @@ describe("getTokenConfig", () => {
|
|
|
35
53
|
expect(cfg.contractAddress).toBe("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb");
|
|
36
54
|
});
|
|
37
55
|
|
|
38
|
-
it("
|
|
56
|
+
it("returns USDC on Ethereum", () => {
|
|
57
|
+
const cfg = getTokenConfig("USDC", "ethereum");
|
|
58
|
+
expect(cfg.decimals).toBe(6);
|
|
59
|
+
expect(cfg.contractAddress).toBe("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns USDT on Ethereum", () => {
|
|
63
|
+
const cfg = getTokenConfig("USDT", "ethereum");
|
|
64
|
+
expect(cfg.decimals).toBe(6);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns DAI on Ethereum", () => {
|
|
68
|
+
const cfg = getTokenConfig("DAI", "ethereum");
|
|
69
|
+
expect(cfg.decimals).toBe(18);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns USDC on Arbitrum", () => {
|
|
73
|
+
const cfg = getTokenConfig("USDC", "arbitrum");
|
|
74
|
+
expect(cfg.chain).toBe("arbitrum");
|
|
75
|
+
expect(cfg.decimals).toBe(6);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns USDT on Polygon", () => {
|
|
79
|
+
const cfg = getTokenConfig("USDT", "polygon");
|
|
80
|
+
expect(cfg.decimals).toBe(6);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws on DAI:polygon (not supported)", () => {
|
|
84
|
+
expect(() => getTokenConfig("DAI", "polygon")).toThrow("Unsupported token");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("throws on unsupported chain", () => {
|
|
39
88
|
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
40
|
-
expect(() => getTokenConfig("USDC" as any, "
|
|
89
|
+
expect(() => getTokenConfig("USDC" as any, "solana" as any)).toThrow("Unsupported token");
|
|
41
90
|
});
|
|
42
91
|
});
|
|
43
92
|
|
|
@@ -20,17 +20,19 @@ function mockTransferLog(to: string, amount: bigint, blockNumber: number) {
|
|
|
20
20
|
|
|
21
21
|
describe("EvmWatcher", () => {
|
|
22
22
|
it("parses Transfer log into EvmPaymentEvent", async () => {
|
|
23
|
+
const toAddr = `0x${"cc".repeat(20)}`;
|
|
23
24
|
const events: { amountUsdCents: number; to: string }[] = [];
|
|
24
25
|
const mockRpc = vi
|
|
25
26
|
.fn()
|
|
26
|
-
.mockResolvedValueOnce(`0x${(102).toString(16)}`)
|
|
27
|
-
.mockResolvedValueOnce([mockTransferLog(
|
|
27
|
+
.mockResolvedValueOnce(`0x${(102).toString(16)}`)
|
|
28
|
+
.mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 99)]);
|
|
28
29
|
|
|
29
30
|
const watcher = new EvmWatcher({
|
|
30
31
|
chain: "base",
|
|
31
32
|
token: "USDC",
|
|
32
33
|
rpcCall: mockRpc,
|
|
33
34
|
fromBlock: 99,
|
|
35
|
+
watchedAddresses: [toAddr],
|
|
34
36
|
onPayment: (evt) => {
|
|
35
37
|
events.push(evt);
|
|
36
38
|
},
|
|
@@ -39,21 +41,22 @@ describe("EvmWatcher", () => {
|
|
|
39
41
|
await watcher.poll();
|
|
40
42
|
|
|
41
43
|
expect(events).toHaveLength(1);
|
|
42
|
-
expect(events[0].amountUsdCents).toBe(1000);
|
|
44
|
+
expect(events[0].amountUsdCents).toBe(1000);
|
|
43
45
|
expect(events[0].to).toMatch(/^0x/);
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
it("advances cursor after processing", async () => {
|
|
47
49
|
const mockRpc = vi
|
|
48
50
|
.fn()
|
|
49
|
-
.mockResolvedValueOnce(`0x${(200).toString(16)}`)
|
|
50
|
-
.mockResolvedValueOnce([]);
|
|
51
|
+
.mockResolvedValueOnce(`0x${(200).toString(16)}`)
|
|
52
|
+
.mockResolvedValueOnce([]);
|
|
51
53
|
|
|
52
54
|
const watcher = new EvmWatcher({
|
|
53
55
|
chain: "base",
|
|
54
56
|
token: "USDC",
|
|
55
57
|
rpcCall: mockRpc,
|
|
56
58
|
fromBlock: 100,
|
|
59
|
+
watchedAddresses: ["0xdeadbeef"],
|
|
57
60
|
onPayment: vi.fn(),
|
|
58
61
|
});
|
|
59
62
|
|
|
@@ -63,15 +66,14 @@ describe("EvmWatcher", () => {
|
|
|
63
66
|
|
|
64
67
|
it("skips blocks not yet confirmed", async () => {
|
|
65
68
|
const events: unknown[] = [];
|
|
66
|
-
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
|
|
69
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
|
|
67
70
|
|
|
68
|
-
// Base needs 1 confirmation, so confirmed = 50 - 1 = 49
|
|
69
|
-
// cursor starts at 50, so confirmed (49) < cursor (50) → no poll
|
|
70
71
|
const watcher = new EvmWatcher({
|
|
71
72
|
chain: "base",
|
|
72
73
|
token: "USDC",
|
|
73
74
|
rpcCall: mockRpc,
|
|
74
75
|
fromBlock: 50,
|
|
76
|
+
watchedAddresses: ["0xdeadbeef"],
|
|
75
77
|
onPayment: (evt) => {
|
|
76
78
|
events.push(evt);
|
|
77
79
|
},
|
|
@@ -79,25 +81,24 @@ describe("EvmWatcher", () => {
|
|
|
79
81
|
|
|
80
82
|
await watcher.poll();
|
|
81
83
|
expect(events).toHaveLength(0);
|
|
82
|
-
// eth_getLogs should not even be called
|
|
83
84
|
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
it("processes multiple logs in one poll", async () => {
|
|
88
|
+
const addr1 = `0x${"aa".repeat(20)}`;
|
|
89
|
+
const addr2 = `0x${"bb".repeat(20)}`;
|
|
87
90
|
const events: { amountUsdCents: number }[] = [];
|
|
88
91
|
const mockRpc = vi
|
|
89
92
|
.fn()
|
|
90
|
-
.mockResolvedValueOnce(`0x${(110).toString(16)}`)
|
|
91
|
-
.mockResolvedValueOnce([
|
|
92
|
-
mockTransferLog(`0x${"aa".repeat(20)}`, 5_000_000n, 105), // $5
|
|
93
|
-
mockTransferLog(`0x${"bb".repeat(20)}`, 20_000_000n, 107), // $20
|
|
94
|
-
]);
|
|
93
|
+
.mockResolvedValueOnce(`0x${(110).toString(16)}`)
|
|
94
|
+
.mockResolvedValueOnce([mockTransferLog(addr1, 5_000_000n, 105), mockTransferLog(addr2, 20_000_000n, 107)]);
|
|
95
95
|
|
|
96
96
|
const watcher = new EvmWatcher({
|
|
97
97
|
chain: "base",
|
|
98
98
|
token: "USDC",
|
|
99
99
|
rpcCall: mockRpc,
|
|
100
100
|
fromBlock: 100,
|
|
101
|
+
watchedAddresses: [addr1, addr2],
|
|
101
102
|
onPayment: (evt) => {
|
|
102
103
|
events.push(evt);
|
|
103
104
|
},
|
|
@@ -111,18 +112,34 @@ describe("EvmWatcher", () => {
|
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
it("does nothing when no new blocks", async () => {
|
|
114
|
-
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`);
|
|
115
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`);
|
|
115
116
|
|
|
116
117
|
const watcher = new EvmWatcher({
|
|
117
118
|
chain: "base",
|
|
118
119
|
token: "USDC",
|
|
119
120
|
rpcCall: mockRpc,
|
|
120
121
|
fromBlock: 100,
|
|
122
|
+
watchedAddresses: ["0xdeadbeef"],
|
|
121
123
|
onPayment: vi.fn(),
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
await watcher.poll();
|
|
125
|
-
expect(watcher.cursor).toBe(100);
|
|
126
|
-
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
127
|
+
expect(watcher.cursor).toBe(100);
|
|
128
|
+
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("early-returns when no watched addresses are set", async () => {
|
|
132
|
+
const mockRpc = vi.fn();
|
|
133
|
+
|
|
134
|
+
const watcher = new EvmWatcher({
|
|
135
|
+
chain: "base",
|
|
136
|
+
token: "USDC",
|
|
137
|
+
rpcCall: mockRpc,
|
|
138
|
+
fromBlock: 0,
|
|
139
|
+
onPayment: vi.fn(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await watcher.poll();
|
|
143
|
+
expect(mockRpc).not.toHaveBeenCalled(); // no RPC calls at all
|
|
127
144
|
});
|
|
128
145
|
});
|
|
@@ -72,8 +72,10 @@ export async function createStablecoinCheckout(
|
|
|
72
72
|
} catch (err: unknown) {
|
|
73
73
|
// Unique constraint violation = another checkout claimed this index concurrently.
|
|
74
74
|
// Retry with the next available index.
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// PostgreSQL error code 23505 = unique_violation.
|
|
76
|
+
// Check structured code first, fall back to message for other drivers.
|
|
77
|
+
const code = (err as { code?: string }).code;
|
|
78
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
77
79
|
if (!isConflict || attempt === maxRetries) throw err;
|
|
78
80
|
}
|
|
79
81
|
}
|
|
@@ -8,9 +8,31 @@ const CHAINS: Record<EvmChain, ChainConfig> = {
|
|
|
8
8
|
blockTimeMs: 2000,
|
|
9
9
|
chainId: 8453,
|
|
10
10
|
},
|
|
11
|
+
ethereum: {
|
|
12
|
+
chain: "ethereum",
|
|
13
|
+
rpcUrl: process.env.EVM_RPC_ETHEREUM ?? "http://geth:8545",
|
|
14
|
+
confirmations: 12,
|
|
15
|
+
blockTimeMs: 12000,
|
|
16
|
+
chainId: 1,
|
|
17
|
+
},
|
|
18
|
+
arbitrum: {
|
|
19
|
+
chain: "arbitrum",
|
|
20
|
+
rpcUrl: process.env.EVM_RPC_ARBITRUM ?? "http://nitro:8547",
|
|
21
|
+
confirmations: 1,
|
|
22
|
+
blockTimeMs: 250,
|
|
23
|
+
chainId: 42161,
|
|
24
|
+
},
|
|
25
|
+
polygon: {
|
|
26
|
+
chain: "polygon",
|
|
27
|
+
rpcUrl: process.env.EVM_RPC_POLYGON ?? "http://bor:8545",
|
|
28
|
+
confirmations: 32,
|
|
29
|
+
blockTimeMs: 2000,
|
|
30
|
+
chainId: 137,
|
|
31
|
+
},
|
|
11
32
|
};
|
|
12
33
|
|
|
13
34
|
const TOKENS: Partial<Record<`${StablecoinToken}:${EvmChain}`, TokenConfig>> = {
|
|
35
|
+
// --- Base ---
|
|
14
36
|
"USDC:base": {
|
|
15
37
|
token: "USDC",
|
|
16
38
|
chain: "base",
|
|
@@ -29,6 +51,57 @@ const TOKENS: Partial<Record<`${StablecoinToken}:${EvmChain}`, TokenConfig>> = {
|
|
|
29
51
|
contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
|
|
30
52
|
decimals: 18,
|
|
31
53
|
},
|
|
54
|
+
// --- Ethereum ---
|
|
55
|
+
"USDC:ethereum": {
|
|
56
|
+
token: "USDC",
|
|
57
|
+
chain: "ethereum",
|
|
58
|
+
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
59
|
+
decimals: 6,
|
|
60
|
+
},
|
|
61
|
+
"USDT:ethereum": {
|
|
62
|
+
token: "USDT",
|
|
63
|
+
chain: "ethereum",
|
|
64
|
+
contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
|
65
|
+
decimals: 6,
|
|
66
|
+
},
|
|
67
|
+
"DAI:ethereum": {
|
|
68
|
+
token: "DAI",
|
|
69
|
+
chain: "ethereum",
|
|
70
|
+
contractAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
|
71
|
+
decimals: 18,
|
|
72
|
+
},
|
|
73
|
+
// --- Arbitrum ---
|
|
74
|
+
"USDC:arbitrum": {
|
|
75
|
+
token: "USDC",
|
|
76
|
+
chain: "arbitrum",
|
|
77
|
+
contractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
78
|
+
decimals: 6,
|
|
79
|
+
},
|
|
80
|
+
"USDT:arbitrum": {
|
|
81
|
+
token: "USDT",
|
|
82
|
+
chain: "arbitrum",
|
|
83
|
+
contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
|
|
84
|
+
decimals: 6,
|
|
85
|
+
},
|
|
86
|
+
"DAI:arbitrum": {
|
|
87
|
+
token: "DAI",
|
|
88
|
+
chain: "arbitrum",
|
|
89
|
+
contractAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
|
|
90
|
+
decimals: 18,
|
|
91
|
+
},
|
|
92
|
+
// --- Polygon ---
|
|
93
|
+
"USDC:polygon": {
|
|
94
|
+
token: "USDC",
|
|
95
|
+
chain: "polygon",
|
|
96
|
+
contractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
97
|
+
decimals: 6,
|
|
98
|
+
},
|
|
99
|
+
"USDT:polygon": {
|
|
100
|
+
token: "USDT",
|
|
101
|
+
chain: "polygon",
|
|
102
|
+
contractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
|
|
103
|
+
decimals: 6,
|
|
104
|
+
},
|
|
32
105
|
};
|
|
33
106
|
|
|
34
107
|
export function getChainConfig(chain: EvmChain): ChainConfig {
|
|
@@ -61,6 +61,8 @@ export class EvmWatcher {
|
|
|
61
61
|
|
|
62
62
|
/** Poll for new Transfer events. Call on an interval. */
|
|
63
63
|
async poll(): Promise<void> {
|
|
64
|
+
if (this._watchedAddresses.length === 0) return; // nothing to watch
|
|
65
|
+
|
|
64
66
|
const latestHex = (await this.rpc("eth_blockNumber", [])) as string;
|
|
65
67
|
const latest = Number.parseInt(latestHex, 16);
|
|
66
68
|
const confirmed = latest - this.confirmations;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from "./btc/index.js";
|
|
2
|
+
export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
|
|
2
3
|
export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
|
|
3
4
|
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
4
5
|
export type { CryptoConfig } from "./client.js";
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -31,7 +31,7 @@ export const cryptoCharges = pgTable(
|
|
|
31
31
|
index("idx_crypto_charges_status").on(table.status),
|
|
32
32
|
index("idx_crypto_charges_created").on(table.createdAt),
|
|
33
33
|
index("idx_crypto_charges_deposit_address").on(table.depositAddress),
|
|
34
|
-
//
|
|
34
|
+
// Unique indexes use WHERE IS NOT NULL partial indexes (declared in migration SQL).
|
|
35
35
|
// Enforced via migration: CREATE UNIQUE INDEX.
|
|
36
36
|
],
|
|
37
37
|
);
|