@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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling wave strategy — processes bots in configurable percentage batches.
|
|
3
|
+
* Create a new instance per rollout; totalFailures accumulates across waves
|
|
4
|
+
* within a single rollout. Call reset() if reusing across rollouts.
|
|
5
|
+
*/
|
|
6
|
+
export class RollingWaveStrategy {
|
|
7
|
+
batchPercent;
|
|
8
|
+
pauseMs;
|
|
9
|
+
maxFailures;
|
|
10
|
+
totalFailures = 0;
|
|
11
|
+
constructor(opts = {}) {
|
|
12
|
+
this.batchPercent = opts.batchPercent ?? 25;
|
|
13
|
+
this.pauseMs = opts.pauseMs ?? 60_000;
|
|
14
|
+
this.maxFailures = opts.maxFailures ?? 3;
|
|
15
|
+
}
|
|
16
|
+
nextBatch(remaining) {
|
|
17
|
+
if (remaining.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const count = Math.max(1, Math.ceil((remaining.length * this.batchPercent) / 100));
|
|
20
|
+
return remaining.slice(0, count);
|
|
21
|
+
}
|
|
22
|
+
pauseDuration() {
|
|
23
|
+
return this.pauseMs;
|
|
24
|
+
}
|
|
25
|
+
onBotFailure(_botId, _error, attempt) {
|
|
26
|
+
if (attempt < this.maxRetries())
|
|
27
|
+
return "retry";
|
|
28
|
+
this.totalFailures++;
|
|
29
|
+
if (this.totalFailures >= this.maxFailures)
|
|
30
|
+
return "abort";
|
|
31
|
+
return "skip";
|
|
32
|
+
}
|
|
33
|
+
maxRetries() {
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
healthCheckTimeout() {
|
|
37
|
+
return 120_000;
|
|
38
|
+
}
|
|
39
|
+
/** Reset failure counters for reuse across rollouts. */
|
|
40
|
+
reset() {
|
|
41
|
+
this.totalFailures = 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class SingleBotStrategy {
|
|
45
|
+
nextBatch(remaining) {
|
|
46
|
+
if (remaining.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
return remaining.slice(0, 1);
|
|
49
|
+
}
|
|
50
|
+
pauseDuration() {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
onBotFailure(_botId, _error, attempt) {
|
|
54
|
+
if (attempt < this.maxRetries())
|
|
55
|
+
return "retry";
|
|
56
|
+
return "abort";
|
|
57
|
+
}
|
|
58
|
+
maxRetries() {
|
|
59
|
+
return 3;
|
|
60
|
+
}
|
|
61
|
+
healthCheckTimeout() {
|
|
62
|
+
return 120_000;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class ImmediateStrategy {
|
|
66
|
+
nextBatch(remaining) {
|
|
67
|
+
return [...remaining];
|
|
68
|
+
}
|
|
69
|
+
pauseDuration() {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
onBotFailure(_botId, _error, _attempt) {
|
|
73
|
+
return "skip";
|
|
74
|
+
}
|
|
75
|
+
maxRetries() {
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
healthCheckTimeout() {
|
|
79
|
+
return 60_000;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function createRolloutStrategy(type, options) {
|
|
83
|
+
switch (type) {
|
|
84
|
+
case "rolling-wave":
|
|
85
|
+
return new RollingWaveStrategy(options);
|
|
86
|
+
case "single-bot":
|
|
87
|
+
return new SingleBotStrategy();
|
|
88
|
+
case "immediate":
|
|
89
|
+
return new ImmediateStrategy();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type Docker from "dockerode";
|
|
2
|
+
export interface VolumeSnapshot {
|
|
3
|
+
id: string;
|
|
4
|
+
volumeName: string;
|
|
5
|
+
archivePath: string;
|
|
6
|
+
createdAt: Date;
|
|
7
|
+
sizeBytes: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Snapshots and restores Docker named volumes using temporary alpine containers.
|
|
11
|
+
* Used for nuclear rollback during fleet updates — if a container update fails,
|
|
12
|
+
* we roll back both the image AND the data volumes.
|
|
13
|
+
*/
|
|
14
|
+
export declare class VolumeSnapshotManager {
|
|
15
|
+
private readonly docker;
|
|
16
|
+
private readonly backupDir;
|
|
17
|
+
constructor(docker: Docker, backupDir?: string);
|
|
18
|
+
/** Create a snapshot of a Docker named volume */
|
|
19
|
+
snapshot(volumeName: string): Promise<VolumeSnapshot>;
|
|
20
|
+
/** Restore a volume from a snapshot */
|
|
21
|
+
restore(snapshotId: string): Promise<void>;
|
|
22
|
+
/** List all snapshots for a volume */
|
|
23
|
+
list(volumeName: string): Promise<VolumeSnapshot[]>;
|
|
24
|
+
/** Delete a snapshot archive */
|
|
25
|
+
delete(snapshotId: string): Promise<void>;
|
|
26
|
+
/** Delete all snapshots older than maxAge ms */
|
|
27
|
+
cleanup(maxAgeMs: number): Promise<number>;
|
|
28
|
+
/**
|
|
29
|
+
* Extract volume name from snapshot ID.
|
|
30
|
+
* Snapshot IDs are `${volumeName}-${ISO timestamp with colons/dots replaced}`.
|
|
31
|
+
* ISO timestamps start with 4 digits (year), so we find the last occurrence
|
|
32
|
+
* of `-YYYY` pattern to split.
|
|
33
|
+
*/
|
|
34
|
+
private extractVolumeName;
|
|
35
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { logger } from "../config/logger.js";
|
|
4
|
+
const ALPINE_IMAGE = "alpine:latest";
|
|
5
|
+
/** Strict validation for snapshot IDs — prevents path traversal and shell injection. */
|
|
6
|
+
const SNAPSHOT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
7
|
+
function validateSnapshotId(snapshotId) {
|
|
8
|
+
if (!SNAPSHOT_ID_RE.test(snapshotId)) {
|
|
9
|
+
throw new Error(`Invalid snapshot ID: ${snapshotId}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Snapshots and restores Docker named volumes using temporary alpine containers.
|
|
14
|
+
* Used for nuclear rollback during fleet updates — if a container update fails,
|
|
15
|
+
* we roll back both the image AND the data volumes.
|
|
16
|
+
*/
|
|
17
|
+
export class VolumeSnapshotManager {
|
|
18
|
+
docker;
|
|
19
|
+
backupDir;
|
|
20
|
+
constructor(docker, backupDir = "/data/fleet/snapshots") {
|
|
21
|
+
this.docker = docker;
|
|
22
|
+
this.backupDir = backupDir;
|
|
23
|
+
}
|
|
24
|
+
/** Create a snapshot of a Docker named volume */
|
|
25
|
+
async snapshot(volumeName) {
|
|
26
|
+
await mkdir(this.backupDir, { recursive: true });
|
|
27
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
28
|
+
const id = `${volumeName}-${timestamp}`;
|
|
29
|
+
const archivePath = join(this.backupDir, `${id}.tar`);
|
|
30
|
+
const container = await this.docker.createContainer({
|
|
31
|
+
Image: ALPINE_IMAGE,
|
|
32
|
+
Cmd: ["tar", "cf", `/backup/${id}.tar`, "-C", "/source", "."],
|
|
33
|
+
HostConfig: {
|
|
34
|
+
Binds: [`${volumeName}:/source:ro`, `${this.backupDir}:/backup`],
|
|
35
|
+
AutoRemove: true,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
await container.start();
|
|
40
|
+
const result = await container.wait();
|
|
41
|
+
if (result.StatusCode !== 0) {
|
|
42
|
+
throw new Error(`Snapshot container exited with code ${result.StatusCode}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// AutoRemove handles cleanup, but if start failed the container may still exist
|
|
47
|
+
try {
|
|
48
|
+
await container.remove({ force: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// already removed by AutoRemove
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
const info = await stat(archivePath);
|
|
56
|
+
const snapshot = {
|
|
57
|
+
id,
|
|
58
|
+
volumeName,
|
|
59
|
+
archivePath,
|
|
60
|
+
createdAt: new Date(),
|
|
61
|
+
sizeBytes: info.size,
|
|
62
|
+
};
|
|
63
|
+
logger.info(`Volume snapshot created: ${id} (${info.size} bytes)`);
|
|
64
|
+
return snapshot;
|
|
65
|
+
}
|
|
66
|
+
/** Restore a volume from a snapshot */
|
|
67
|
+
async restore(snapshotId) {
|
|
68
|
+
validateSnapshotId(snapshotId);
|
|
69
|
+
const archivePath = join(this.backupDir, `${snapshotId}.tar`);
|
|
70
|
+
// Verify archive exists
|
|
71
|
+
await stat(archivePath);
|
|
72
|
+
// Extract volume name from snapshot ID (everything before the last ISO timestamp)
|
|
73
|
+
const volumeName = this.extractVolumeName(snapshotId);
|
|
74
|
+
const container = await this.docker.createContainer({
|
|
75
|
+
Image: ALPINE_IMAGE,
|
|
76
|
+
Cmd: ["sh", "-c", `cd /target && rm -rf ./* ./.??* && tar xf /backup/${snapshotId}.tar -C /target`],
|
|
77
|
+
HostConfig: {
|
|
78
|
+
Binds: [`${volumeName}:/target`, `${this.backupDir}:/backup:ro`],
|
|
79
|
+
AutoRemove: true,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
await container.start();
|
|
84
|
+
const result = await container.wait();
|
|
85
|
+
if (result.StatusCode !== 0) {
|
|
86
|
+
throw new Error(`Restore container exited with code ${result.StatusCode}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
try {
|
|
91
|
+
await container.remove({ force: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// already removed by AutoRemove
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
logger.info(`Volume restored from snapshot: ${snapshotId}`);
|
|
99
|
+
}
|
|
100
|
+
/** List all snapshots for a volume */
|
|
101
|
+
async list(volumeName) {
|
|
102
|
+
let files;
|
|
103
|
+
try {
|
|
104
|
+
files = await readdir(this.backupDir);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const prefix = `${volumeName}-`;
|
|
110
|
+
const matching = files.filter((f) => f.startsWith(prefix) && f.endsWith(".tar"));
|
|
111
|
+
const snapshots = [];
|
|
112
|
+
for (const file of matching) {
|
|
113
|
+
const id = file.replace(/\.tar$/, "");
|
|
114
|
+
const archivePath = join(this.backupDir, file);
|
|
115
|
+
try {
|
|
116
|
+
const info = await stat(archivePath);
|
|
117
|
+
snapshots.push({
|
|
118
|
+
id,
|
|
119
|
+
volumeName,
|
|
120
|
+
archivePath,
|
|
121
|
+
createdAt: info.mtime,
|
|
122
|
+
sizeBytes: info.size,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// File disappeared between readdir and stat — skip
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Sort newest first
|
|
130
|
+
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
131
|
+
return snapshots;
|
|
132
|
+
}
|
|
133
|
+
/** Delete a snapshot archive */
|
|
134
|
+
async delete(snapshotId) {
|
|
135
|
+
validateSnapshotId(snapshotId);
|
|
136
|
+
const archivePath = join(this.backupDir, `${snapshotId}.tar`);
|
|
137
|
+
await rm(archivePath, { force: true });
|
|
138
|
+
logger.info(`Volume snapshot deleted: ${snapshotId}`);
|
|
139
|
+
}
|
|
140
|
+
/** Delete all snapshots older than maxAge ms */
|
|
141
|
+
async cleanup(maxAgeMs) {
|
|
142
|
+
let files;
|
|
143
|
+
try {
|
|
144
|
+
files = await readdir(this.backupDir);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
150
|
+
let deleted = 0;
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
if (!file.endsWith(".tar"))
|
|
153
|
+
continue;
|
|
154
|
+
const archivePath = join(this.backupDir, file);
|
|
155
|
+
try {
|
|
156
|
+
const info = await stat(archivePath);
|
|
157
|
+
if (info.mtime.getTime() < cutoff) {
|
|
158
|
+
await rm(archivePath, { force: true });
|
|
159
|
+
deleted++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// File disappeared — skip
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (deleted > 0) {
|
|
167
|
+
logger.info(`Volume snapshot cleanup: removed ${deleted} old snapshots`);
|
|
168
|
+
}
|
|
169
|
+
return deleted;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Extract volume name from snapshot ID.
|
|
173
|
+
* Snapshot IDs are `${volumeName}-${ISO timestamp with colons/dots replaced}`.
|
|
174
|
+
* ISO timestamps start with 4 digits (year), so we find the last occurrence
|
|
175
|
+
* of `-YYYY` pattern to split.
|
|
176
|
+
*/
|
|
177
|
+
extractVolumeName(snapshotId) {
|
|
178
|
+
// Match the timestamp part: -YYYY-MM-DDTHH-MM-SS-MMMZ
|
|
179
|
+
const match = snapshotId.match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
|
|
180
|
+
if (!match) {
|
|
181
|
+
throw new Error(`Cannot extract volume name from snapshot ID: ${snapshotId}`);
|
|
182
|
+
}
|
|
183
|
+
return match[1];
|
|
184
|
+
}
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wopr-network/platform-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -128,8 +128,10 @@
|
|
|
128
128
|
},
|
|
129
129
|
"packageManager": "pnpm@10.31.0",
|
|
130
130
|
"dependencies": {
|
|
131
|
+
"@noble/hashes": "^2.0.1",
|
|
131
132
|
"@scure/base": "^2.0.0",
|
|
132
133
|
"@scure/bip32": "^2.0.1",
|
|
134
|
+
"@scure/bip39": "^2.0.1",
|
|
133
135
|
"js-yaml": "^4.1.1",
|
|
134
136
|
"viem": "^2.47.4",
|
|
135
137
|
"yaml": "^2.8.2"
|
|
@@ -2,7 +2,7 @@ import type { AuditEntry } from "../../admin/audit-log.js";
|
|
|
2
2
|
|
|
3
3
|
/** Minimal interface for admin audit logging in route factories. */
|
|
4
4
|
export interface AdminAuditLogger {
|
|
5
|
-
log(entry: AuditEntry):
|
|
5
|
+
log(entry: AuditEntry): undefined | Promise<unknown>;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/** Safely log an admin audit entry — never throws. */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { deriveBtcAddress, deriveBtcTreasury } from "../address-gen.js";
|
|
4
|
+
|
|
5
|
+
function makeTestXpub(): string {
|
|
6
|
+
const seed = new Uint8Array(32);
|
|
7
|
+
seed[0] = 1;
|
|
8
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
9
|
+
return master.derive("m/44'/0'/0'").publicExtendedKey;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TEST_XPUB = makeTestXpub();
|
|
13
|
+
|
|
14
|
+
describe("deriveBtcAddress", () => {
|
|
15
|
+
it("derives a valid bech32 address", () => {
|
|
16
|
+
const addr = deriveBtcAddress(TEST_XPUB, 0);
|
|
17
|
+
expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("derives different addresses for different indices", () => {
|
|
21
|
+
const a = deriveBtcAddress(TEST_XPUB, 0);
|
|
22
|
+
const b = deriveBtcAddress(TEST_XPUB, 1);
|
|
23
|
+
expect(a).not.toBe(b);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("is deterministic", () => {
|
|
27
|
+
const a = deriveBtcAddress(TEST_XPUB, 42);
|
|
28
|
+
const b = deriveBtcAddress(TEST_XPUB, 42);
|
|
29
|
+
expect(a).toBe(b);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses tb prefix for testnet/regtest", () => {
|
|
33
|
+
const addr = deriveBtcAddress(TEST_XPUB, 0, "testnet");
|
|
34
|
+
expect(addr).toMatch(/^tb1q[a-z0-9]+$/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects negative index", () => {
|
|
38
|
+
expect(() => deriveBtcAddress(TEST_XPUB, -1)).toThrow("Invalid");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("deriveBtcTreasury", () => {
|
|
43
|
+
it("derives a valid bech32 address", () => {
|
|
44
|
+
const addr = deriveBtcTreasury(TEST_XPUB);
|
|
45
|
+
expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("differs from deposit address at index 0", () => {
|
|
49
|
+
const deposit = deriveBtcAddress(TEST_XPUB, 0);
|
|
50
|
+
const treasury = deriveBtcTreasury(TEST_XPUB);
|
|
51
|
+
expect(deposit).not.toBe(treasury);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { centsToSats, satsToCents } from "../config.js";
|
|
3
|
+
|
|
4
|
+
describe("centsToSats", () => {
|
|
5
|
+
it("converts $10 at $100k BTC", () => {
|
|
6
|
+
// $10 = 1000 cents, BTC at $100,000
|
|
7
|
+
// 10 / 100000 = 0.0001 BTC = 10000 sats
|
|
8
|
+
expect(centsToSats(1000, 100_000)).toBe(10000);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("converts $100 at $50k BTC", () => {
|
|
12
|
+
// $100 = 10000 cents, BTC at $50,000
|
|
13
|
+
// 100 / 50000 = 0.002 BTC = 200000 sats
|
|
14
|
+
expect(centsToSats(10000, 50_000)).toBe(200000);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("satsToCents", () => {
|
|
19
|
+
it("converts 10000 sats at $100k BTC", () => {
|
|
20
|
+
// 10000 sats = 0.0001 BTC, at $100k = $10 = 1000 cents
|
|
21
|
+
expect(satsToCents(10000, 100_000)).toBe(1000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rounds to nearest cent", () => {
|
|
25
|
+
// 15000 sats at $100k = 0.00015 BTC = $15 = 1500 cents
|
|
26
|
+
expect(satsToCents(15000, 100_000)).toBe(1500);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { settleBtcPayment } from "../settler.js";
|
|
3
|
+
import type { BtcPaymentEvent } from "../types.js";
|
|
4
|
+
|
|
5
|
+
const mockEvent: BtcPaymentEvent = {
|
|
6
|
+
address: "bc1qtest",
|
|
7
|
+
txid: "abc123",
|
|
8
|
+
amountSats: 15000,
|
|
9
|
+
amountUsdCents: 1000,
|
|
10
|
+
confirmations: 6,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("settleBtcPayment", () => {
|
|
14
|
+
it("credits ledger when charge found", async () => {
|
|
15
|
+
const deps = {
|
|
16
|
+
chargeStore: {
|
|
17
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
18
|
+
referenceId: "btc:test",
|
|
19
|
+
tenantId: "t1",
|
|
20
|
+
amountUsdCents: 1000,
|
|
21
|
+
creditedAt: null,
|
|
22
|
+
}),
|
|
23
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
},
|
|
26
|
+
creditLedger: {
|
|
27
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
28
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
29
|
+
},
|
|
30
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const result = await settleBtcPayment(deps as never, mockEvent);
|
|
34
|
+
expect(result.handled).toBe(true);
|
|
35
|
+
expect(result.creditedCents).toBe(1000);
|
|
36
|
+
expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
|
|
37
|
+
|
|
38
|
+
// Verify Credit.fromCents was used
|
|
39
|
+
const creditArg = deps.creditLedger.credit.mock.calls[0][1];
|
|
40
|
+
expect(creditArg.toCentsRounded()).toBe(1000);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects double-credit on already-credited charge", async () => {
|
|
44
|
+
const deps = {
|
|
45
|
+
chargeStore: {
|
|
46
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
47
|
+
referenceId: "btc:test",
|
|
48
|
+
tenantId: "t1",
|
|
49
|
+
amountUsdCents: 1000,
|
|
50
|
+
creditedAt: "2026-01-01",
|
|
51
|
+
}),
|
|
52
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
markCredited: vi.fn(),
|
|
54
|
+
},
|
|
55
|
+
creditLedger: {
|
|
56
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
57
|
+
credit: vi.fn(),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await settleBtcPayment(deps as never, mockEvent);
|
|
62
|
+
expect(result.creditedCents).toBe(0);
|
|
63
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects underpayment", async () => {
|
|
67
|
+
const underpaid = { ...mockEvent, amountUsdCents: 500 };
|
|
68
|
+
const deps = {
|
|
69
|
+
chargeStore: {
|
|
70
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
71
|
+
referenceId: "btc:test",
|
|
72
|
+
tenantId: "t1",
|
|
73
|
+
amountUsdCents: 1000,
|
|
74
|
+
creditedAt: null,
|
|
75
|
+
}),
|
|
76
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
markCredited: vi.fn(),
|
|
78
|
+
},
|
|
79
|
+
creditLedger: {
|
|
80
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
81
|
+
credit: vi.fn(),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const result = await settleBtcPayment(deps as never, underpaid);
|
|
86
|
+
expect(result.creditedCents).toBe(0);
|
|
87
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns handled:false when no charge found", async () => {
|
|
91
|
+
const deps = {
|
|
92
|
+
chargeStore: {
|
|
93
|
+
getByDepositAddress: vi.fn().mockResolvedValue(null),
|
|
94
|
+
updateStatus: vi.fn(),
|
|
95
|
+
markCredited: vi.fn(),
|
|
96
|
+
},
|
|
97
|
+
creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const result = await settleBtcPayment(deps as never, mockEvent);
|
|
101
|
+
expect(result.handled).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Derive a native segwit (bech32, bc1q...) BTC address from an xpub at a given index.
|
|
8
|
+
* Path: xpub / 0 / index (external chain).
|
|
9
|
+
* No private keys involved.
|
|
10
|
+
*/
|
|
11
|
+
export function deriveBtcAddress(
|
|
12
|
+
xpub: string,
|
|
13
|
+
index: number,
|
|
14
|
+
network: "mainnet" | "testnet" | "regtest" = "mainnet",
|
|
15
|
+
): string {
|
|
16
|
+
if (!Number.isInteger(index) || index < 0) throw new Error(`Invalid derivation index: ${index}`);
|
|
17
|
+
|
|
18
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
19
|
+
const child = master.deriveChild(0).deriveChild(index);
|
|
20
|
+
if (!child.publicKey) throw new Error("Failed to derive public key");
|
|
21
|
+
|
|
22
|
+
// HASH160 = RIPEMD160(SHA256(compressedPubKey))
|
|
23
|
+
const hash160 = ripemd160(sha256(child.publicKey));
|
|
24
|
+
|
|
25
|
+
// Bech32 encode: witness version 0 + 20-byte hash
|
|
26
|
+
const prefix = network === "mainnet" ? "bc" : "tb";
|
|
27
|
+
const words = bech32.toWords(hash160);
|
|
28
|
+
return bech32.encode(prefix, [0, ...words]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Derive the BTC treasury address (internal chain, index 0). */
|
|
32
|
+
export function deriveBtcTreasury(xpub: string, network: "mainnet" | "testnet" | "regtest" = "mainnet"): string {
|
|
33
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
34
|
+
const child = master.deriveChild(1).deriveChild(0); // internal chain
|
|
35
|
+
if (!child.publicKey) throw new Error("Failed to derive public key");
|
|
36
|
+
|
|
37
|
+
const hash160 = ripemd160(sha256(child.publicKey));
|
|
38
|
+
const prefix = network === "mainnet" ? "bc" : "tb";
|
|
39
|
+
const words = bech32.toWords(hash160);
|
|
40
|
+
return bech32.encode(prefix, [0, ...words]);
|
|
41
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Credit } from "../../../credits/credit.js";
|
|
2
|
+
import type { ICryptoChargeRepository } from "../charge-store.js";
|
|
3
|
+
import { deriveBtcAddress } from "./address-gen.js";
|
|
4
|
+
import type { BtcCheckoutOpts } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const MIN_BTC_USD = 10;
|
|
7
|
+
|
|
8
|
+
export interface BtcCheckoutDeps {
|
|
9
|
+
chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
|
|
10
|
+
xpub: string;
|
|
11
|
+
network?: "mainnet" | "testnet" | "regtest";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BtcCheckoutResult {
|
|
15
|
+
depositAddress: string;
|
|
16
|
+
amountUsd: number;
|
|
17
|
+
referenceId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a BTC checkout — derive a unique deposit address, store the charge.
|
|
22
|
+
*
|
|
23
|
+
* Same pattern as stablecoin checkout: HD derivation + charge store + retry on conflict.
|
|
24
|
+
*
|
|
25
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
26
|
+
*/
|
|
27
|
+
export async function createBtcCheckout(deps: BtcCheckoutDeps, opts: BtcCheckoutOpts): Promise<BtcCheckoutResult> {
|
|
28
|
+
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_BTC_USD) {
|
|
29
|
+
throw new Error(`Minimum payment amount is $${MIN_BTC_USD}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
33
|
+
const network = deps.network ?? "mainnet";
|
|
34
|
+
const maxRetries = 3;
|
|
35
|
+
|
|
36
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
37
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
38
|
+
const depositAddress = deriveBtcAddress(deps.xpub, derivationIndex, network);
|
|
39
|
+
const referenceId = `btc:${depositAddress}`;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
43
|
+
referenceId,
|
|
44
|
+
tenantId: opts.tenant,
|
|
45
|
+
amountUsdCents,
|
|
46
|
+
chain: "bitcoin",
|
|
47
|
+
token: "BTC",
|
|
48
|
+
depositAddress,
|
|
49
|
+
derivationIndex,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return { depositAddress, amountUsd: opts.amountUsd, referenceId };
|
|
53
|
+
} catch (err: unknown) {
|
|
54
|
+
const code = (err as { code?: string }).code;
|
|
55
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
56
|
+
if (!isConflict || attempt === maxRetries) throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BitcoindConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function loadBitcoindConfig(): BitcoindConfig | null {
|
|
4
|
+
const rpcUrl = process.env.BITCOIND_RPC_URL;
|
|
5
|
+
const rpcUser = process.env.BITCOIND_RPC_USER;
|
|
6
|
+
const rpcPassword = process.env.BITCOIND_RPC_PASSWORD;
|
|
7
|
+
if (!rpcUrl || !rpcUser || !rpcPassword) return null;
|
|
8
|
+
|
|
9
|
+
const network = (process.env.BITCOIND_NETWORK ?? "mainnet") as BitcoindConfig["network"];
|
|
10
|
+
const confirmations = network === "regtest" ? 1 : 6;
|
|
11
|
+
|
|
12
|
+
return { rpcUrl, rpcUser, rpcPassword, network, confirmations };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert USD cents to satoshis given a BTC/USD price.
|
|
17
|
+
* Integer math only.
|
|
18
|
+
*/
|
|
19
|
+
export function centsToSats(cents: number, btcPriceUsd: number): number {
|
|
20
|
+
// 1 BTC = 100_000_000 sats, price is in dollars
|
|
21
|
+
// cents / 100 = dollars, dollars / btcPrice = BTC, BTC * 1e8 = sats
|
|
22
|
+
// To avoid float: (cents * 1e8) / (btcPrice * 100)
|
|
23
|
+
return Math.round((cents * 100_000_000) / (btcPriceUsd * 100));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert satoshis to USD cents given a BTC/USD price.
|
|
28
|
+
* Integer math only (rounds to nearest cent).
|
|
29
|
+
*/
|
|
30
|
+
export function satsToCents(sats: number, btcPriceUsd: number): number {
|
|
31
|
+
// sats / 1e8 = BTC, BTC * btcPrice = dollars, dollars * 100 = cents
|
|
32
|
+
return Math.round((sats * btcPriceUsd * 100) / 100_000_000);
|
|
33
|
+
}
|
|
@@ -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";
|