@wopr-network/platform-core 1.19.0 → 1.21.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/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 +3 -1
- package/dist/billing/crypto/index.js +2 -0
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +83 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.js +51 -0
- package/dist/billing/crypto/oracle/__tests__/fixed.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/fixed.test.js +20 -0
- package/dist/billing/crypto/oracle/chainlink.d.ts +26 -0
- package/dist/billing/crypto/oracle/chainlink.js +61 -0
- package/dist/billing/crypto/oracle/convert.d.ts +20 -0
- package/dist/billing/crypto/oracle/convert.js +38 -0
- package/dist/billing/crypto/oracle/fixed.d.ts +10 -0
- package/dist/billing/crypto/oracle/fixed.js +20 -0
- package/dist/billing/crypto/oracle/index.d.ts +5 -0
- package/dist/billing/crypto/oracle/index.js +3 -0
- package/dist/billing/crypto/oracle/types.d.ts +13 -0
- package/dist/billing/crypto/oracle/types.js +1 -0
- package/dist/db/schema/crypto.js +1 -1
- package/package.json +3 -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 +3 -1
- package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +107 -0
- package/src/billing/crypto/oracle/__tests__/convert.test.ts +62 -0
- package/src/billing/crypto/oracle/__tests__/fixed.test.ts +23 -0
- package/src/billing/crypto/oracle/chainlink.ts +85 -0
- package/src/billing/crypto/oracle/convert.ts +38 -0
- package/src/billing/crypto/oracle/fixed.ts +23 -0
- package/src/billing/crypto/oracle/index.ts +5 -0
- package/src/billing/crypto/oracle/types.ts +15 -0
- package/src/db/schema/crypto.ts +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { deriveBtcAddress, deriveBtcTreasury } from "../address-gen.js";
|
|
4
|
+
function makeTestXpub() {
|
|
5
|
+
const seed = new Uint8Array(32);
|
|
6
|
+
seed[0] = 1;
|
|
7
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
8
|
+
return master.derive("m/44'/0'/0'").publicExtendedKey;
|
|
9
|
+
}
|
|
10
|
+
const TEST_XPUB = makeTestXpub();
|
|
11
|
+
describe("deriveBtcAddress", () => {
|
|
12
|
+
it("derives a valid bech32 address", () => {
|
|
13
|
+
const addr = deriveBtcAddress(TEST_XPUB, 0);
|
|
14
|
+
expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
|
|
15
|
+
});
|
|
16
|
+
it("derives different addresses for different indices", () => {
|
|
17
|
+
const a = deriveBtcAddress(TEST_XPUB, 0);
|
|
18
|
+
const b = deriveBtcAddress(TEST_XPUB, 1);
|
|
19
|
+
expect(a).not.toBe(b);
|
|
20
|
+
});
|
|
21
|
+
it("is deterministic", () => {
|
|
22
|
+
const a = deriveBtcAddress(TEST_XPUB, 42);
|
|
23
|
+
const b = deriveBtcAddress(TEST_XPUB, 42);
|
|
24
|
+
expect(a).toBe(b);
|
|
25
|
+
});
|
|
26
|
+
it("uses tb prefix for testnet/regtest", () => {
|
|
27
|
+
const addr = deriveBtcAddress(TEST_XPUB, 0, "testnet");
|
|
28
|
+
expect(addr).toMatch(/^tb1q[a-z0-9]+$/);
|
|
29
|
+
});
|
|
30
|
+
it("rejects negative index", () => {
|
|
31
|
+
expect(() => deriveBtcAddress(TEST_XPUB, -1)).toThrow("Invalid");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe("deriveBtcTreasury", () => {
|
|
35
|
+
it("derives a valid bech32 address", () => {
|
|
36
|
+
const addr = deriveBtcTreasury(TEST_XPUB);
|
|
37
|
+
expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
|
|
38
|
+
});
|
|
39
|
+
it("differs from deposit address at index 0", () => {
|
|
40
|
+
const deposit = deriveBtcAddress(TEST_XPUB, 0);
|
|
41
|
+
const treasury = deriveBtcTreasury(TEST_XPUB);
|
|
42
|
+
expect(deposit).not.toBe(treasury);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { centsToSats, satsToCents } from "../config.js";
|
|
3
|
+
describe("centsToSats", () => {
|
|
4
|
+
it("converts $10 at $100k BTC", () => {
|
|
5
|
+
// $10 = 1000 cents, BTC at $100,000
|
|
6
|
+
// 10 / 100000 = 0.0001 BTC = 10000 sats
|
|
7
|
+
expect(centsToSats(1000, 100_000)).toBe(10000);
|
|
8
|
+
});
|
|
9
|
+
it("converts $100 at $50k BTC", () => {
|
|
10
|
+
// $100 = 10000 cents, BTC at $50,000
|
|
11
|
+
// 100 / 50000 = 0.002 BTC = 200000 sats
|
|
12
|
+
expect(centsToSats(10000, 50_000)).toBe(200000);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("satsToCents", () => {
|
|
16
|
+
it("converts 10000 sats at $100k BTC", () => {
|
|
17
|
+
// 10000 sats = 0.0001 BTC, at $100k = $10 = 1000 cents
|
|
18
|
+
expect(satsToCents(10000, 100_000)).toBe(1000);
|
|
19
|
+
});
|
|
20
|
+
it("rounds to nearest cent", () => {
|
|
21
|
+
// 15000 sats at $100k = 0.00015 BTC = $15 = 1500 cents
|
|
22
|
+
expect(satsToCents(15000, 100_000)).toBe(1500);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { settleBtcPayment } from "../settler.js";
|
|
3
|
+
const mockEvent = {
|
|
4
|
+
address: "bc1qtest",
|
|
5
|
+
txid: "abc123",
|
|
6
|
+
amountSats: 15000,
|
|
7
|
+
amountUsdCents: 1000,
|
|
8
|
+
confirmations: 6,
|
|
9
|
+
};
|
|
10
|
+
describe("settleBtcPayment", () => {
|
|
11
|
+
it("credits ledger when charge found", async () => {
|
|
12
|
+
const deps = {
|
|
13
|
+
chargeStore: {
|
|
14
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
15
|
+
referenceId: "btc:test",
|
|
16
|
+
tenantId: "t1",
|
|
17
|
+
amountUsdCents: 1000,
|
|
18
|
+
creditedAt: null,
|
|
19
|
+
}),
|
|
20
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
},
|
|
23
|
+
creditLedger: {
|
|
24
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
25
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
26
|
+
},
|
|
27
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
28
|
+
};
|
|
29
|
+
const result = await settleBtcPayment(deps, mockEvent);
|
|
30
|
+
expect(result.handled).toBe(true);
|
|
31
|
+
expect(result.creditedCents).toBe(1000);
|
|
32
|
+
expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
|
|
33
|
+
// Verify Credit.fromCents was used
|
|
34
|
+
const creditArg = deps.creditLedger.credit.mock.calls[0][1];
|
|
35
|
+
expect(creditArg.toCentsRounded()).toBe(1000);
|
|
36
|
+
});
|
|
37
|
+
it("rejects double-credit on already-credited charge", async () => {
|
|
38
|
+
const deps = {
|
|
39
|
+
chargeStore: {
|
|
40
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
41
|
+
referenceId: "btc:test",
|
|
42
|
+
tenantId: "t1",
|
|
43
|
+
amountUsdCents: 1000,
|
|
44
|
+
creditedAt: "2026-01-01",
|
|
45
|
+
}),
|
|
46
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
markCredited: vi.fn(),
|
|
48
|
+
},
|
|
49
|
+
creditLedger: {
|
|
50
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
51
|
+
credit: vi.fn(),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const result = await settleBtcPayment(deps, mockEvent);
|
|
55
|
+
expect(result.creditedCents).toBe(0);
|
|
56
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it("rejects underpayment", async () => {
|
|
59
|
+
const underpaid = { ...mockEvent, amountUsdCents: 500 };
|
|
60
|
+
const deps = {
|
|
61
|
+
chargeStore: {
|
|
62
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
63
|
+
referenceId: "btc:test",
|
|
64
|
+
tenantId: "t1",
|
|
65
|
+
amountUsdCents: 1000,
|
|
66
|
+
creditedAt: null,
|
|
67
|
+
}),
|
|
68
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
69
|
+
markCredited: vi.fn(),
|
|
70
|
+
},
|
|
71
|
+
creditLedger: {
|
|
72
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
73
|
+
credit: vi.fn(),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const result = await settleBtcPayment(deps, underpaid);
|
|
77
|
+
expect(result.creditedCents).toBe(0);
|
|
78
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
it("returns handled:false when no charge found", async () => {
|
|
81
|
+
const deps = {
|
|
82
|
+
chargeStore: {
|
|
83
|
+
getByDepositAddress: vi.fn().mockResolvedValue(null),
|
|
84
|
+
updateStatus: vi.fn(),
|
|
85
|
+
markCredited: vi.fn(),
|
|
86
|
+
},
|
|
87
|
+
creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
|
|
88
|
+
};
|
|
89
|
+
const result = await settleBtcPayment(deps, mockEvent);
|
|
90
|
+
expect(result.handled).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a native segwit (bech32, bc1q...) BTC address from an xpub at a given index.
|
|
3
|
+
* Path: xpub / 0 / index (external chain).
|
|
4
|
+
* No private keys involved.
|
|
5
|
+
*/
|
|
6
|
+
export declare function deriveBtcAddress(xpub: string, index: number, network?: "mainnet" | "testnet" | "regtest"): string;
|
|
7
|
+
/** Derive the BTC treasury address (internal chain, index 0). */
|
|
8
|
+
export declare function deriveBtcTreasury(xpub: string, network?: "mainnet" | "testnet" | "regtest"): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ripemd160 } from "@noble/hashes/legacy.js";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
+
import { bech32 } from "@scure/base";
|
|
4
|
+
import { HDKey } from "@scure/bip32";
|
|
5
|
+
/**
|
|
6
|
+
* Derive a native segwit (bech32, bc1q...) BTC address from an xpub at a given index.
|
|
7
|
+
* Path: xpub / 0 / index (external chain).
|
|
8
|
+
* No private keys involved.
|
|
9
|
+
*/
|
|
10
|
+
export function deriveBtcAddress(xpub, index, network = "mainnet") {
|
|
11
|
+
if (!Number.isInteger(index) || index < 0)
|
|
12
|
+
throw new Error(`Invalid derivation index: ${index}`);
|
|
13
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
14
|
+
const child = master.deriveChild(0).deriveChild(index);
|
|
15
|
+
if (!child.publicKey)
|
|
16
|
+
throw new Error("Failed to derive public key");
|
|
17
|
+
// HASH160 = RIPEMD160(SHA256(compressedPubKey))
|
|
18
|
+
const hash160 = ripemd160(sha256(child.publicKey));
|
|
19
|
+
// Bech32 encode: witness version 0 + 20-byte hash
|
|
20
|
+
const prefix = network === "mainnet" ? "bc" : "tb";
|
|
21
|
+
const words = bech32.toWords(hash160);
|
|
22
|
+
return bech32.encode(prefix, [0, ...words]);
|
|
23
|
+
}
|
|
24
|
+
/** Derive the BTC treasury address (internal chain, index 0). */
|
|
25
|
+
export function deriveBtcTreasury(xpub, network = "mainnet") {
|
|
26
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
27
|
+
const child = master.deriveChild(1).deriveChild(0); // internal chain
|
|
28
|
+
if (!child.publicKey)
|
|
29
|
+
throw new Error("Failed to derive public key");
|
|
30
|
+
const hash160 = ripemd160(sha256(child.publicKey));
|
|
31
|
+
const prefix = network === "mainnet" ? "bc" : "tb";
|
|
32
|
+
const words = bech32.toWords(hash160);
|
|
33
|
+
return bech32.encode(prefix, [0, ...words]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ICryptoChargeRepository } from "../charge-store.js";
|
|
2
|
+
import type { BtcCheckoutOpts } from "./types.js";
|
|
3
|
+
export declare const MIN_BTC_USD = 10;
|
|
4
|
+
export interface BtcCheckoutDeps {
|
|
5
|
+
chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
|
|
6
|
+
xpub: string;
|
|
7
|
+
network?: "mainnet" | "testnet" | "regtest";
|
|
8
|
+
}
|
|
9
|
+
export interface BtcCheckoutResult {
|
|
10
|
+
depositAddress: string;
|
|
11
|
+
amountUsd: number;
|
|
12
|
+
referenceId: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create a BTC checkout — derive a unique deposit address, store the charge.
|
|
16
|
+
*
|
|
17
|
+
* Same pattern as stablecoin checkout: HD derivation + charge store + retry on conflict.
|
|
18
|
+
*
|
|
19
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
20
|
+
*/
|
|
21
|
+
export declare function createBtcCheckout(deps: BtcCheckoutDeps, opts: BtcCheckoutOpts): Promise<BtcCheckoutResult>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Credit } from "../../../credits/credit.js";
|
|
2
|
+
import { deriveBtcAddress } from "./address-gen.js";
|
|
3
|
+
export const MIN_BTC_USD = 10;
|
|
4
|
+
/**
|
|
5
|
+
* Create a BTC checkout — derive a unique deposit address, store the charge.
|
|
6
|
+
*
|
|
7
|
+
* Same pattern as stablecoin checkout: HD derivation + charge store + retry on conflict.
|
|
8
|
+
*
|
|
9
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
10
|
+
*/
|
|
11
|
+
export async function createBtcCheckout(deps, opts) {
|
|
12
|
+
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_BTC_USD) {
|
|
13
|
+
throw new Error(`Minimum payment amount is $${MIN_BTC_USD}`);
|
|
14
|
+
}
|
|
15
|
+
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
16
|
+
const network = deps.network ?? "mainnet";
|
|
17
|
+
const maxRetries = 3;
|
|
18
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
19
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
20
|
+
const depositAddress = deriveBtcAddress(deps.xpub, derivationIndex, network);
|
|
21
|
+
const referenceId = `btc:${depositAddress}`;
|
|
22
|
+
try {
|
|
23
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
24
|
+
referenceId,
|
|
25
|
+
tenantId: opts.tenant,
|
|
26
|
+
amountUsdCents,
|
|
27
|
+
chain: "bitcoin",
|
|
28
|
+
token: "BTC",
|
|
29
|
+
depositAddress,
|
|
30
|
+
derivationIndex,
|
|
31
|
+
});
|
|
32
|
+
return { depositAddress, amountUsd: opts.amountUsd, referenceId };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const code = err.code;
|
|
36
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
37
|
+
if (!isConflict || attempt === maxRetries)
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
42
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BitcoindConfig } from "./types.js";
|
|
2
|
+
export declare function loadBitcoindConfig(): BitcoindConfig | null;
|
|
3
|
+
/**
|
|
4
|
+
* Convert USD cents to satoshis given a BTC/USD price.
|
|
5
|
+
* Integer math only.
|
|
6
|
+
*/
|
|
7
|
+
export declare function centsToSats(cents: number, btcPriceUsd: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* Convert satoshis to USD cents given a BTC/USD price.
|
|
10
|
+
* Integer math only (rounds to nearest cent).
|
|
11
|
+
*/
|
|
12
|
+
export declare function satsToCents(sats: number, btcPriceUsd: number): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function loadBitcoindConfig() {
|
|
2
|
+
const rpcUrl = process.env.BITCOIND_RPC_URL;
|
|
3
|
+
const rpcUser = process.env.BITCOIND_RPC_USER;
|
|
4
|
+
const rpcPassword = process.env.BITCOIND_RPC_PASSWORD;
|
|
5
|
+
if (!rpcUrl || !rpcUser || !rpcPassword)
|
|
6
|
+
return null;
|
|
7
|
+
const network = (process.env.BITCOIND_NETWORK ?? "mainnet");
|
|
8
|
+
const confirmations = network === "regtest" ? 1 : 6;
|
|
9
|
+
return { rpcUrl, rpcUser, rpcPassword, network, confirmations };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Convert USD cents to satoshis given a BTC/USD price.
|
|
13
|
+
* Integer math only.
|
|
14
|
+
*/
|
|
15
|
+
export function centsToSats(cents, btcPriceUsd) {
|
|
16
|
+
// 1 BTC = 100_000_000 sats, price is in dollars
|
|
17
|
+
// cents / 100 = dollars, dollars / btcPrice = BTC, BTC * 1e8 = sats
|
|
18
|
+
// To avoid float: (cents * 1e8) / (btcPrice * 100)
|
|
19
|
+
return Math.round((cents * 100_000_000) / (btcPriceUsd * 100));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Convert satoshis to USD cents given a BTC/USD price.
|
|
23
|
+
* Integer math only (rounds to nearest cent).
|
|
24
|
+
*/
|
|
25
|
+
export function satsToCents(sats, btcPriceUsd) {
|
|
26
|
+
// sats / 1e8 = BTC, BTC * btcPrice = dollars, dollars * 100 = cents
|
|
27
|
+
return Math.round((sats * btcPriceUsd * 100) / 100_000_000);
|
|
28
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { deriveBtcAddress, deriveBtcTreasury } from "./address-gen.js";
|
|
2
|
+
export type { BtcCheckoutDeps, BtcCheckoutResult } from "./checkout.js";
|
|
3
|
+
export { createBtcCheckout, MIN_BTC_USD } from "./checkout.js";
|
|
4
|
+
export { centsToSats, loadBitcoindConfig, satsToCents } from "./config.js";
|
|
5
|
+
export type { BtcSettlerDeps } from "./settler.js";
|
|
6
|
+
export { settleBtcPayment } from "./settler.js";
|
|
7
|
+
export type { BitcoindConfig, BtcCheckoutOpts, BtcPaymentEvent } from "./types.js";
|
|
8
|
+
export type { BtcWatcherOpts } from "./watcher.js";
|
|
9
|
+
export { BtcWatcher, createBitcoindRpc } from "./watcher.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { deriveBtcAddress, deriveBtcTreasury } from "./address-gen.js";
|
|
2
|
+
export { createBtcCheckout, MIN_BTC_USD } from "./checkout.js";
|
|
3
|
+
export { centsToSats, loadBitcoindConfig, satsToCents } from "./config.js";
|
|
4
|
+
export { settleBtcPayment } from "./settler.js";
|
|
5
|
+
export { BtcWatcher, createBitcoindRpc } from "./watcher.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ILedger } from "../../../credits/ledger.js";
|
|
2
|
+
import type { ICryptoChargeRepository } from "../charge-store.js";
|
|
3
|
+
import type { CryptoWebhookResult } from "../types.js";
|
|
4
|
+
import type { BtcPaymentEvent } from "./types.js";
|
|
5
|
+
export interface BtcSettlerDeps {
|
|
6
|
+
chargeStore: Pick<ICryptoChargeRepository, "getByDepositAddress" | "updateStatus" | "markCredited">;
|
|
7
|
+
creditLedger: Pick<ILedger, "credit" | "hasReferenceId">;
|
|
8
|
+
onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Settle a BTC payment — look up charge by deposit address, credit ledger.
|
|
12
|
+
*
|
|
13
|
+
* Same idempotency pattern as EVM settler and BTCPay webhook handler:
|
|
14
|
+
* 1. Charge-level: check creditedAt (prevents second tx double-credit)
|
|
15
|
+
* 2. Transfer-level: creditLedger.hasReferenceId (atomic)
|
|
16
|
+
* 3. Advisory: chargeStore.markCredited
|
|
17
|
+
*
|
|
18
|
+
* Credits the CHARGE amount (not the BTC amount) for consistency.
|
|
19
|
+
*
|
|
20
|
+
* CRITICAL: charge.amountUsdCents is in USD cents (integer).
|
|
21
|
+
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
22
|
+
*/
|
|
23
|
+
export declare function settleBtcPayment(deps: BtcSettlerDeps, event: BtcPaymentEvent): Promise<CryptoWebhookResult>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Credit } from "../../../credits/credit.js";
|
|
2
|
+
/**
|
|
3
|
+
* Settle a BTC payment — look up charge by deposit address, credit ledger.
|
|
4
|
+
*
|
|
5
|
+
* Same idempotency pattern as EVM settler and BTCPay webhook handler:
|
|
6
|
+
* 1. Charge-level: check creditedAt (prevents second tx double-credit)
|
|
7
|
+
* 2. Transfer-level: creditLedger.hasReferenceId (atomic)
|
|
8
|
+
* 3. Advisory: chargeStore.markCredited
|
|
9
|
+
*
|
|
10
|
+
* Credits the CHARGE amount (not the BTC amount) for consistency.
|
|
11
|
+
*
|
|
12
|
+
* CRITICAL: charge.amountUsdCents is in USD cents (integer).
|
|
13
|
+
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
14
|
+
*/
|
|
15
|
+
export async function settleBtcPayment(deps, event) {
|
|
16
|
+
const { chargeStore, creditLedger } = deps;
|
|
17
|
+
const charge = await chargeStore.getByDepositAddress(event.address);
|
|
18
|
+
if (!charge) {
|
|
19
|
+
return { handled: false, status: "Settled" };
|
|
20
|
+
}
|
|
21
|
+
await chargeStore.updateStatus(charge.referenceId, "Settled");
|
|
22
|
+
// Charge-level idempotency
|
|
23
|
+
if (charge.creditedAt != null) {
|
|
24
|
+
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
25
|
+
}
|
|
26
|
+
// Transfer-level idempotency
|
|
27
|
+
const creditRef = `btc:${event.txid}`;
|
|
28
|
+
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
29
|
+
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
30
|
+
}
|
|
31
|
+
// Underpayment check
|
|
32
|
+
if (event.amountUsdCents < charge.amountUsdCents) {
|
|
33
|
+
return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
|
|
34
|
+
}
|
|
35
|
+
const creditCents = charge.amountUsdCents;
|
|
36
|
+
await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
|
|
37
|
+
description: `BTC credit purchase (txid: ${event.txid})`,
|
|
38
|
+
referenceId: creditRef,
|
|
39
|
+
fundingSource: "crypto",
|
|
40
|
+
});
|
|
41
|
+
await chargeStore.markCredited(charge.referenceId);
|
|
42
|
+
let reactivatedBots;
|
|
43
|
+
if (deps.onCreditsPurchased) {
|
|
44
|
+
reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
|
|
45
|
+
if (reactivatedBots.length === 0)
|
|
46
|
+
reactivatedBots = undefined;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
handled: true,
|
|
50
|
+
status: "Settled",
|
|
51
|
+
tenant: charge.tenantId,
|
|
52
|
+
creditedCents: creditCents,
|
|
53
|
+
reactivatedBots,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
/** Options for creating a BTC checkout. */
|
|
12
|
+
export interface BtcCheckoutOpts {
|
|
13
|
+
tenant: string;
|
|
14
|
+
amountUsd: number;
|
|
15
|
+
}
|
|
16
|
+
/** Bitcoind RPC configuration. */
|
|
17
|
+
export interface BitcoindConfig {
|
|
18
|
+
readonly rpcUrl: string;
|
|
19
|
+
readonly rpcUser: string;
|
|
20
|
+
readonly rpcPassword: string;
|
|
21
|
+
readonly network: "mainnet" | "testnet" | "regtest";
|
|
22
|
+
readonly confirmations: number;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
|
|
2
|
+
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
3
|
+
export interface BtcWatcherOpts {
|
|
4
|
+
config: BitcoindConfig;
|
|
5
|
+
rpcCall: RpcCall;
|
|
6
|
+
/** Addresses to watch (must be imported into bitcoind wallet first). */
|
|
7
|
+
watchedAddresses: string[];
|
|
8
|
+
onPayment: (event: BtcPaymentEvent) => void | Promise<void>;
|
|
9
|
+
/** Current BTC/USD price for conversion. */
|
|
10
|
+
getBtcPrice: () => Promise<number>;
|
|
11
|
+
}
|
|
12
|
+
export declare class BtcWatcher {
|
|
13
|
+
private readonly rpc;
|
|
14
|
+
private readonly addresses;
|
|
15
|
+
private readonly onPayment;
|
|
16
|
+
private readonly minConfirmations;
|
|
17
|
+
private readonly getBtcPrice;
|
|
18
|
+
constructor(opts: BtcWatcherOpts);
|
|
19
|
+
/** Update the set of watched addresses. */
|
|
20
|
+
setWatchedAddresses(addresses: string[]): void;
|
|
21
|
+
/** Import an address into bitcoind's wallet (watch-only, no rescan). */
|
|
22
|
+
importAddress(address: string): Promise<void>;
|
|
23
|
+
/** Poll for confirmed payments to watched addresses. */
|
|
24
|
+
poll(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** Create a bitcoind JSON-RPC caller with basic auth. */
|
|
27
|
+
export declare function createBitcoindRpc(config: BitcoindConfig): RpcCall;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Track which txids we've already processed to avoid double-crediting. */
|
|
2
|
+
const processedTxids = new Set();
|
|
3
|
+
export class BtcWatcher {
|
|
4
|
+
rpc;
|
|
5
|
+
addresses;
|
|
6
|
+
onPayment;
|
|
7
|
+
minConfirmations;
|
|
8
|
+
getBtcPrice;
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
this.rpc = opts.rpcCall;
|
|
11
|
+
this.addresses = new Set(opts.watchedAddresses);
|
|
12
|
+
this.onPayment = opts.onPayment;
|
|
13
|
+
this.minConfirmations = opts.config.confirmations;
|
|
14
|
+
this.getBtcPrice = opts.getBtcPrice;
|
|
15
|
+
}
|
|
16
|
+
/** Update the set of watched addresses. */
|
|
17
|
+
setWatchedAddresses(addresses) {
|
|
18
|
+
this.addresses.clear();
|
|
19
|
+
for (const a of addresses)
|
|
20
|
+
this.addresses.add(a);
|
|
21
|
+
}
|
|
22
|
+
/** Import an address into bitcoind's wallet (watch-only, no rescan). */
|
|
23
|
+
async importAddress(address) {
|
|
24
|
+
await this.rpc("importaddress", [address, "", false]);
|
|
25
|
+
this.addresses.add(address);
|
|
26
|
+
}
|
|
27
|
+
/** Poll for confirmed payments to watched addresses. */
|
|
28
|
+
async poll() {
|
|
29
|
+
if (this.addresses.size === 0)
|
|
30
|
+
return;
|
|
31
|
+
const received = (await this.rpc("listreceivedbyaddress", [
|
|
32
|
+
this.minConfirmations,
|
|
33
|
+
false, // include_empty
|
|
34
|
+
true, // include_watchonly
|
|
35
|
+
]));
|
|
36
|
+
const btcPrice = await this.getBtcPrice();
|
|
37
|
+
for (const entry of received) {
|
|
38
|
+
if (!this.addresses.has(entry.address))
|
|
39
|
+
continue;
|
|
40
|
+
for (const txid of entry.txids) {
|
|
41
|
+
if (processedTxids.has(txid))
|
|
42
|
+
continue;
|
|
43
|
+
processedTxids.add(txid);
|
|
44
|
+
// Get transaction details for the exact amount sent to this address
|
|
45
|
+
const tx = (await this.rpc("gettransaction", [txid, true]));
|
|
46
|
+
const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
|
|
47
|
+
if (!detail)
|
|
48
|
+
continue;
|
|
49
|
+
const amountSats = Math.round(detail.amount * 100_000_000);
|
|
50
|
+
const amountUsdCents = Math.round(detail.amount * btcPrice * 100);
|
|
51
|
+
const event = {
|
|
52
|
+
address: entry.address,
|
|
53
|
+
txid,
|
|
54
|
+
amountSats,
|
|
55
|
+
amountUsdCents,
|
|
56
|
+
confirmations: tx.confirmations,
|
|
57
|
+
};
|
|
58
|
+
await this.onPayment(event);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Create a bitcoind JSON-RPC caller with basic auth. */
|
|
64
|
+
export function createBitcoindRpc(config) {
|
|
65
|
+
let id = 0;
|
|
66
|
+
const auth = btoa(`${config.rpcUser}:${config.rpcPassword}`);
|
|
67
|
+
return async (method, params) => {
|
|
68
|
+
const res = await fetch(config.rpcUrl, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
Authorization: `Basic ${auth}`,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ jsonrpc: "1.0", id: ++id, method, params }),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error(`bitcoind ${method} failed: ${res.status}`);
|
|
78
|
+
const data = (await res.json());
|
|
79
|
+
if (data.error)
|
|
80
|
+
throw new Error(`bitcoind ${method}: ${data.error.message}`);
|
|
81
|
+
return data.result;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -15,7 +15,7 @@ export interface CryptoChargeRecord {
|
|
|
15
15
|
depositAddress: string | null;
|
|
16
16
|
derivationIndex: number | null;
|
|
17
17
|
}
|
|
18
|
-
export interface
|
|
18
|
+
export interface CryptoDepositChargeInput {
|
|
19
19
|
referenceId: string;
|
|
20
20
|
tenantId: string;
|
|
21
21
|
amountUsdCents: number;
|
|
@@ -30,7 +30,7 @@ export interface ICryptoChargeRepository {
|
|
|
30
30
|
updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
|
|
31
31
|
markCredited(referenceId: string): Promise<void>;
|
|
32
32
|
isCredited(referenceId: string): Promise<boolean>;
|
|
33
|
-
createStablecoinCharge(input:
|
|
33
|
+
createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
|
|
34
34
|
getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
|
|
35
35
|
getNextDerivationIndex(): Promise<number>;
|
|
36
36
|
}
|
|
@@ -59,7 +59,7 @@ export declare class DrizzleCryptoChargeRepository implements ICryptoChargeRepos
|
|
|
59
59
|
/** Check if a charge has already been credited (for idempotency). */
|
|
60
60
|
isCredited(referenceId: string): Promise<boolean>;
|
|
61
61
|
/** Create a stablecoin charge with chain/token/deposit address. */
|
|
62
|
-
createStablecoinCharge(input:
|
|
62
|
+
createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
|
|
63
63
|
/** Look up a charge by its deposit address. */
|
|
64
64
|
getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
|
|
65
65
|
/** Get the next available HD derivation index (max + 1, or 0 if empty). */
|
|
@@ -7,6 +7,21 @@ describe("getChainConfig", () => {
|
|
|
7
7
|
expect(cfg.confirmations).toBe(1);
|
|
8
8
|
expect(cfg.blockTimeMs).toBe(2000);
|
|
9
9
|
});
|
|
10
|
+
it("returns Ethereum config", () => {
|
|
11
|
+
const cfg = getChainConfig("ethereum");
|
|
12
|
+
expect(cfg.chainId).toBe(1);
|
|
13
|
+
expect(cfg.confirmations).toBe(12);
|
|
14
|
+
});
|
|
15
|
+
it("returns Arbitrum config", () => {
|
|
16
|
+
const cfg = getChainConfig("arbitrum");
|
|
17
|
+
expect(cfg.chainId).toBe(42161);
|
|
18
|
+
expect(cfg.confirmations).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
it("returns Polygon config", () => {
|
|
21
|
+
const cfg = getChainConfig("polygon");
|
|
22
|
+
expect(cfg.chainId).toBe(137);
|
|
23
|
+
expect(cfg.confirmations).toBe(32);
|
|
24
|
+
});
|
|
10
25
|
it("throws on unknown chain", () => {
|
|
11
26
|
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
12
27
|
expect(() => getChainConfig("solana")).toThrow("Unsupported chain");
|
|
@@ -29,9 +44,34 @@ describe("getTokenConfig", () => {
|
|
|
29
44
|
expect(cfg.decimals).toBe(18);
|
|
30
45
|
expect(cfg.contractAddress).toBe("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb");
|
|
31
46
|
});
|
|
32
|
-
it("
|
|
47
|
+
it("returns USDC on Ethereum", () => {
|
|
48
|
+
const cfg = getTokenConfig("USDC", "ethereum");
|
|
49
|
+
expect(cfg.decimals).toBe(6);
|
|
50
|
+
expect(cfg.contractAddress).toBe("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
|
|
51
|
+
});
|
|
52
|
+
it("returns USDT on Ethereum", () => {
|
|
53
|
+
const cfg = getTokenConfig("USDT", "ethereum");
|
|
54
|
+
expect(cfg.decimals).toBe(6);
|
|
55
|
+
});
|
|
56
|
+
it("returns DAI on Ethereum", () => {
|
|
57
|
+
const cfg = getTokenConfig("DAI", "ethereum");
|
|
58
|
+
expect(cfg.decimals).toBe(18);
|
|
59
|
+
});
|
|
60
|
+
it("returns USDC on Arbitrum", () => {
|
|
61
|
+
const cfg = getTokenConfig("USDC", "arbitrum");
|
|
62
|
+
expect(cfg.chain).toBe("arbitrum");
|
|
63
|
+
expect(cfg.decimals).toBe(6);
|
|
64
|
+
});
|
|
65
|
+
it("returns USDT on Polygon", () => {
|
|
66
|
+
const cfg = getTokenConfig("USDT", "polygon");
|
|
67
|
+
expect(cfg.decimals).toBe(6);
|
|
68
|
+
});
|
|
69
|
+
it("throws on DAI:polygon (not supported)", () => {
|
|
70
|
+
expect(() => getTokenConfig("DAI", "polygon")).toThrow("Unsupported token");
|
|
71
|
+
});
|
|
72
|
+
it("throws on unsupported chain", () => {
|
|
33
73
|
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
34
|
-
expect(() => getTokenConfig("USDC", "
|
|
74
|
+
expect(() => getTokenConfig("USDC", "solana")).toThrow("Unsupported token");
|
|
35
75
|
});
|
|
36
76
|
});
|
|
37
77
|
describe("tokenAmountFromCents", () => {
|