@wopr-network/platform-core 1.24.0 → 1.26.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/index.d.ts +4 -0
- package/dist/billing/crypto/index.js +2 -0
- package/dist/billing/crypto/payment-method-store.d.ts +38 -0
- package/dist/billing/crypto/payment-method-store.js +82 -0
- package/dist/billing/crypto/unified-checkout.d.ts +35 -0
- package/dist/billing/crypto/unified-checkout.js +128 -0
- package/dist/db/schema/crypto.d.ts +216 -0
- package/dist/db/schema/crypto.js +20 -1
- package/dist/fleet/__tests__/init-fleet-updater.test.d.ts +1 -0
- package/dist/fleet/__tests__/init-fleet-updater.test.js +93 -0
- package/dist/fleet/index.d.ts +1 -0
- package/dist/fleet/index.js +1 -0
- package/dist/fleet/init-fleet-updater.d.ts +58 -0
- package/dist/fleet/init-fleet-updater.js +88 -0
- package/drizzle/migrations/0008_payment_methods.sql +22 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/index.ts +4 -0
- package/src/billing/crypto/payment-method-store.ts +117 -0
- package/src/billing/crypto/unified-checkout.ts +190 -0
- package/src/db/schema/crypto.ts +21 -1
- package/src/fleet/__tests__/init-fleet-updater.test.ts +129 -0
- package/src/fleet/index.ts +1 -0
- package/src/fleet/init-fleet-updater.ts +134 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
|
|
3
|
+
*
|
|
4
|
+
* Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
|
|
5
|
+
* The pipeline detects new image digests, batches updates via a rollout strategy,
|
|
6
|
+
* snapshots volumes before updating, and restores on failure (nuclear rollback).
|
|
7
|
+
*
|
|
8
|
+
* When a new image digest is detected for ANY bot, the orchestrator triggers a
|
|
9
|
+
* fleet-wide rollout across all non-manual bots. This is intentional: the managed
|
|
10
|
+
* Paperclip image is shared across all tenants, so a single digest change means
|
|
11
|
+
* all bots need updating.
|
|
12
|
+
*/
|
|
13
|
+
import type Docker from "dockerode";
|
|
14
|
+
import type { IBotProfileRepository } from "./bot-profile-repository.js";
|
|
15
|
+
import type { FleetManager } from "./fleet-manager.js";
|
|
16
|
+
import { ImagePoller } from "./image-poller.js";
|
|
17
|
+
import type { IProfileStore } from "./profile-store.js";
|
|
18
|
+
import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
|
|
19
|
+
import { type RollingWaveOptions } from "./rollout-strategy.js";
|
|
20
|
+
import { ContainerUpdater } from "./updater.js";
|
|
21
|
+
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
22
|
+
export interface FleetUpdaterConfig {
|
|
23
|
+
/** Rollout strategy type. Default: "rolling-wave" */
|
|
24
|
+
strategy?: "rolling-wave" | "single-bot" | "immediate";
|
|
25
|
+
/** Options for RollingWaveStrategy (ignored for other strategies) */
|
|
26
|
+
strategyOptions?: RollingWaveOptions;
|
|
27
|
+
/** Directory for volume snapshots. Default: "/data/fleet/snapshots" */
|
|
28
|
+
snapshotDir?: string;
|
|
29
|
+
/** Called after each bot update */
|
|
30
|
+
onBotUpdated?: (result: {
|
|
31
|
+
botId: string;
|
|
32
|
+
success: boolean;
|
|
33
|
+
volumeRestored: boolean;
|
|
34
|
+
}) => void;
|
|
35
|
+
/** Called when a rollout completes */
|
|
36
|
+
onRolloutComplete?: (result: RolloutResult) => void;
|
|
37
|
+
}
|
|
38
|
+
export interface FleetUpdaterHandle {
|
|
39
|
+
poller: ImagePoller;
|
|
40
|
+
updater: ContainerUpdater;
|
|
41
|
+
orchestrator: RolloutOrchestrator;
|
|
42
|
+
snapshotManager: VolumeSnapshotManager;
|
|
43
|
+
/** Stop the poller and wait for any active rollout to finish */
|
|
44
|
+
stop: () => Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Initialize the fleet auto-update pipeline.
|
|
48
|
+
*
|
|
49
|
+
* Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
|
|
50
|
+
* with VolumeSnapshotManager for nuclear rollback.
|
|
51
|
+
*
|
|
52
|
+
* @param docker - Dockerode instance for container operations
|
|
53
|
+
* @param fleet - FleetManager for container lifecycle
|
|
54
|
+
* @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
|
|
55
|
+
* @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
|
|
56
|
+
* @param config - Optional pipeline configuration
|
|
57
|
+
*/
|
|
58
|
+
export declare function initFleetUpdater(docker: Docker, fleet: FleetManager, profileStore: IProfileStore, profileRepo: IBotProfileRepository, config?: FleetUpdaterConfig): FleetUpdaterHandle;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
|
|
3
|
+
*
|
|
4
|
+
* Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
|
|
5
|
+
* The pipeline detects new image digests, batches updates via a rollout strategy,
|
|
6
|
+
* snapshots volumes before updating, and restores on failure (nuclear rollback).
|
|
7
|
+
*
|
|
8
|
+
* When a new image digest is detected for ANY bot, the orchestrator triggers a
|
|
9
|
+
* fleet-wide rollout across all non-manual bots. This is intentional: the managed
|
|
10
|
+
* Paperclip image is shared across all tenants, so a single digest change means
|
|
11
|
+
* all bots need updating.
|
|
12
|
+
*/
|
|
13
|
+
import { logger } from "../config/logger.js";
|
|
14
|
+
import { ImagePoller } from "./image-poller.js";
|
|
15
|
+
import { RolloutOrchestrator } from "./rollout-orchestrator.js";
|
|
16
|
+
import { createRolloutStrategy } from "./rollout-strategy.js";
|
|
17
|
+
import { ContainerUpdater } from "./updater.js";
|
|
18
|
+
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
19
|
+
/**
|
|
20
|
+
* Initialize the fleet auto-update pipeline.
|
|
21
|
+
*
|
|
22
|
+
* Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
|
|
23
|
+
* with VolumeSnapshotManager for nuclear rollback.
|
|
24
|
+
*
|
|
25
|
+
* @param docker - Dockerode instance for container operations
|
|
26
|
+
* @param fleet - FleetManager for container lifecycle
|
|
27
|
+
* @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
|
|
28
|
+
* @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
|
|
29
|
+
* @param config - Optional pipeline configuration
|
|
30
|
+
*/
|
|
31
|
+
export function initFleetUpdater(docker, fleet, profileStore, profileRepo, config = {}) {
|
|
32
|
+
const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, } = config;
|
|
33
|
+
const poller = new ImagePoller(docker, profileStore);
|
|
34
|
+
const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
|
|
35
|
+
const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
|
|
36
|
+
const strategy = createRolloutStrategy(strategyType, strategyOptions);
|
|
37
|
+
const orchestrator = new RolloutOrchestrator({
|
|
38
|
+
updater,
|
|
39
|
+
snapshotManager,
|
|
40
|
+
strategy,
|
|
41
|
+
getUpdatableProfiles: async () => {
|
|
42
|
+
const profiles = await profileRepo.list();
|
|
43
|
+
return profiles.filter((p) => p.updatePolicy !== "manual");
|
|
44
|
+
},
|
|
45
|
+
onBotUpdated,
|
|
46
|
+
onRolloutComplete,
|
|
47
|
+
});
|
|
48
|
+
// Wire the detection → orchestration pipeline.
|
|
49
|
+
// Any digest change triggers a fleet-wide rollout because the managed image
|
|
50
|
+
// is shared across all tenants — one new digest means all bots need updating.
|
|
51
|
+
poller.onUpdateAvailable = async (_botId, _newDigest) => {
|
|
52
|
+
if (orchestrator.isRolling) {
|
|
53
|
+
logger.debug("Skipping update trigger — rollout already in progress");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
logger.info("New image digest detected — starting fleet-wide rollout");
|
|
57
|
+
await orchestrator.rollout().catch((err) => {
|
|
58
|
+
logger.error("Rollout failed", { err });
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
// Start polling
|
|
62
|
+
poller.start().catch((err) => {
|
|
63
|
+
logger.error("ImagePoller failed to start", { err });
|
|
64
|
+
});
|
|
65
|
+
logger.info("Fleet auto-update pipeline initialized", {
|
|
66
|
+
strategy: strategyType,
|
|
67
|
+
snapshotDir,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
poller,
|
|
71
|
+
updater,
|
|
72
|
+
orchestrator,
|
|
73
|
+
snapshotManager,
|
|
74
|
+
stop: async () => {
|
|
75
|
+
poller.stop();
|
|
76
|
+
// Wait for any in-flight rollout to complete before returning
|
|
77
|
+
if (orchestrator.isRolling) {
|
|
78
|
+
logger.info("Waiting for active rollout to finish before shutdown...");
|
|
79
|
+
// Poll until rollout finishes (max 5 minutes)
|
|
80
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
81
|
+
while (orchestrator.isRolling && Date.now() < deadline) {
|
|
82
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
logger.info("Fleet auto-update pipeline stopped");
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "payment_methods" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"type" text NOT NULL,
|
|
4
|
+
"token" text NOT NULL,
|
|
5
|
+
"chain" text NOT NULL,
|
|
6
|
+
"contract_address" text,
|
|
7
|
+
"decimals" integer NOT NULL,
|
|
8
|
+
"display_name" text NOT NULL,
|
|
9
|
+
"enabled" boolean NOT NULL DEFAULT true,
|
|
10
|
+
"display_order" integer NOT NULL DEFAULT 0,
|
|
11
|
+
"rpc_url" text,
|
|
12
|
+
"confirmations" integer NOT NULL DEFAULT 1,
|
|
13
|
+
"created_at" text DEFAULT (now()) NOT NULL
|
|
14
|
+
);
|
|
15
|
+
--> statement-breakpoint
|
|
16
|
+
INSERT INTO "payment_methods" ("id", "type", "token", "chain", "contract_address", "decimals", "display_name", "display_order", "confirmations") VALUES
|
|
17
|
+
('USDC:base', 'erc20', 'USDC', 'base', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 6, 'USDC on Base', 0, 1),
|
|
18
|
+
('USDT:base', 'erc20', 'USDT', 'base', '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', 6, 'USDT on Base', 1, 1),
|
|
19
|
+
('DAI:base', 'erc20', 'DAI', 'base', '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', 18, 'DAI on Base', 2, 1),
|
|
20
|
+
('ETH:base', 'native', 'ETH', 'base', NULL, 18, 'ETH on Base', 3, 1),
|
|
21
|
+
('BTC:mainnet', 'native', 'BTC', 'bitcoin', NULL, 8, 'Bitcoin', 10, 3)
|
|
22
|
+
ON CONFLICT ("id") DO NOTHING;
|
package/package.json
CHANGED
|
@@ -8,6 +8,8 @@ export type { IWatcherCursorStore } from "./cursor-store.js";
|
|
|
8
8
|
export { DrizzleWatcherCursorStore } from "./cursor-store.js";
|
|
9
9
|
export * from "./evm/index.js";
|
|
10
10
|
export * from "./oracle/index.js";
|
|
11
|
+
export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
|
|
12
|
+
export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
|
|
11
13
|
export type {
|
|
12
14
|
CryptoBillingConfig,
|
|
13
15
|
CryptoCheckoutOpts,
|
|
@@ -16,5 +18,7 @@ export type {
|
|
|
16
18
|
CryptoWebhookResult,
|
|
17
19
|
} from "./types.js";
|
|
18
20
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
21
|
+
export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
|
|
22
|
+
export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
|
|
19
23
|
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
20
24
|
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { PlatformDb } from "../../db/index.js";
|
|
3
|
+
import { paymentMethods } from "../../db/schema/crypto.js";
|
|
4
|
+
|
|
5
|
+
export interface PaymentMethodRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
type: string;
|
|
8
|
+
token: string;
|
|
9
|
+
chain: string;
|
|
10
|
+
contractAddress: string | null;
|
|
11
|
+
decimals: number;
|
|
12
|
+
displayName: string;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
displayOrder: number;
|
|
15
|
+
rpcUrl: string | null;
|
|
16
|
+
confirmations: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IPaymentMethodStore {
|
|
20
|
+
/** List all enabled payment methods, ordered by displayOrder. */
|
|
21
|
+
listEnabled(): Promise<PaymentMethodRecord[]>;
|
|
22
|
+
/** List all payment methods (including disabled). */
|
|
23
|
+
listAll(): Promise<PaymentMethodRecord[]>;
|
|
24
|
+
/** Get a specific payment method by id. */
|
|
25
|
+
getById(id: string): Promise<PaymentMethodRecord | null>;
|
|
26
|
+
/** Get enabled methods by type (stablecoin, eth, btc). */
|
|
27
|
+
listByType(type: string): Promise<PaymentMethodRecord[]>;
|
|
28
|
+
/** Upsert a payment method (admin). */
|
|
29
|
+
upsert(method: PaymentMethodRecord): Promise<void>;
|
|
30
|
+
/** Enable or disable a payment method (admin). */
|
|
31
|
+
setEnabled(id: string, enabled: boolean): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class DrizzlePaymentMethodStore implements IPaymentMethodStore {
|
|
35
|
+
constructor(private readonly db: PlatformDb) {}
|
|
36
|
+
|
|
37
|
+
async listEnabled(): Promise<PaymentMethodRecord[]> {
|
|
38
|
+
const rows = await this.db
|
|
39
|
+
.select()
|
|
40
|
+
.from(paymentMethods)
|
|
41
|
+
.where(eq(paymentMethods.enabled, true))
|
|
42
|
+
.orderBy(paymentMethods.displayOrder);
|
|
43
|
+
return rows.map(toRecord);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async listAll(): Promise<PaymentMethodRecord[]> {
|
|
47
|
+
const rows = await this.db.select().from(paymentMethods).orderBy(paymentMethods.displayOrder);
|
|
48
|
+
return rows.map(toRecord);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getById(id: string): Promise<PaymentMethodRecord | null> {
|
|
52
|
+
const row = (await this.db.select().from(paymentMethods).where(eq(paymentMethods.id, id)))[0];
|
|
53
|
+
return row ? toRecord(row) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async listByType(type: string): Promise<PaymentMethodRecord[]> {
|
|
57
|
+
const rows = await this.db
|
|
58
|
+
.select()
|
|
59
|
+
.from(paymentMethods)
|
|
60
|
+
.where(and(eq(paymentMethods.type, type), eq(paymentMethods.enabled, true)))
|
|
61
|
+
.orderBy(paymentMethods.displayOrder);
|
|
62
|
+
return rows.map(toRecord);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async upsert(method: PaymentMethodRecord): Promise<void> {
|
|
66
|
+
await this.db
|
|
67
|
+
.insert(paymentMethods)
|
|
68
|
+
.values({
|
|
69
|
+
id: method.id,
|
|
70
|
+
type: method.type,
|
|
71
|
+
token: method.token,
|
|
72
|
+
chain: method.chain,
|
|
73
|
+
contractAddress: method.contractAddress,
|
|
74
|
+
decimals: method.decimals,
|
|
75
|
+
displayName: method.displayName,
|
|
76
|
+
enabled: method.enabled,
|
|
77
|
+
displayOrder: method.displayOrder,
|
|
78
|
+
rpcUrl: method.rpcUrl,
|
|
79
|
+
confirmations: method.confirmations,
|
|
80
|
+
})
|
|
81
|
+
.onConflictDoUpdate({
|
|
82
|
+
target: paymentMethods.id,
|
|
83
|
+
set: {
|
|
84
|
+
type: method.type,
|
|
85
|
+
token: method.token,
|
|
86
|
+
chain: method.chain,
|
|
87
|
+
contractAddress: method.contractAddress,
|
|
88
|
+
decimals: method.decimals,
|
|
89
|
+
displayName: method.displayName,
|
|
90
|
+
enabled: method.enabled,
|
|
91
|
+
displayOrder: method.displayOrder,
|
|
92
|
+
rpcUrl: method.rpcUrl,
|
|
93
|
+
confirmations: method.confirmations,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
|
99
|
+
await this.db.update(paymentMethods).set({ enabled }).where(eq(paymentMethods.id, id));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toRecord(row: typeof paymentMethods.$inferSelect): PaymentMethodRecord {
|
|
104
|
+
return {
|
|
105
|
+
id: row.id,
|
|
106
|
+
type: row.type,
|
|
107
|
+
token: row.token,
|
|
108
|
+
chain: row.chain,
|
|
109
|
+
contractAddress: row.contractAddress,
|
|
110
|
+
decimals: row.decimals,
|
|
111
|
+
displayName: row.displayName,
|
|
112
|
+
enabled: row.enabled,
|
|
113
|
+
displayOrder: row.displayOrder,
|
|
114
|
+
rpcUrl: row.rpcUrl,
|
|
115
|
+
confirmations: row.confirmations,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Credit } from "../../credits/credit.js";
|
|
2
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
3
|
+
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
4
|
+
import { centsToNative } from "./oracle/convert.js";
|
|
5
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
6
|
+
import type { PaymentMethodRecord } from "./payment-method-store.js";
|
|
7
|
+
|
|
8
|
+
export const MIN_CHECKOUT_USD = 10;
|
|
9
|
+
|
|
10
|
+
export interface UnifiedCheckoutDeps {
|
|
11
|
+
chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
|
|
12
|
+
oracle: IPriceOracle;
|
|
13
|
+
evmXpub: string;
|
|
14
|
+
btcXpub?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UnifiedCheckoutResult {
|
|
18
|
+
depositAddress: string;
|
|
19
|
+
/** Human-readable amount to send (e.g. "50 USDC", "0.014285 ETH"). */
|
|
20
|
+
displayAmount: string;
|
|
21
|
+
amountUsd: number;
|
|
22
|
+
token: string;
|
|
23
|
+
chain: string;
|
|
24
|
+
referenceId: string;
|
|
25
|
+
/** For volatile assets: price at checkout time (USD cents per unit). */
|
|
26
|
+
priceCents?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unified checkout — one entry point for all payment methods.
|
|
31
|
+
*
|
|
32
|
+
* Looks up the method record, routes by type:
|
|
33
|
+
* - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
|
|
34
|
+
* - native (ETH): derives EVM address, oracle-priced
|
|
35
|
+
* - native (BTC): derives BTC address, oracle-priced
|
|
36
|
+
*
|
|
37
|
+
* CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
|
|
38
|
+
*/
|
|
39
|
+
export async function createUnifiedCheckout(
|
|
40
|
+
deps: UnifiedCheckoutDeps,
|
|
41
|
+
method: PaymentMethodRecord,
|
|
42
|
+
opts: { tenant: string; amountUsd: number },
|
|
43
|
+
): Promise<UnifiedCheckoutResult> {
|
|
44
|
+
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
|
|
45
|
+
throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
49
|
+
|
|
50
|
+
if (method.type === "erc20") {
|
|
51
|
+
return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
52
|
+
}
|
|
53
|
+
if (method.token === "ETH") {
|
|
54
|
+
return handleNativeEth(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
55
|
+
}
|
|
56
|
+
if (method.token === "BTC") {
|
|
57
|
+
return handleNativeBtc(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleErc20(
|
|
64
|
+
deps: UnifiedCheckoutDeps,
|
|
65
|
+
method: PaymentMethodRecord,
|
|
66
|
+
tenant: string,
|
|
67
|
+
amountUsdCents: number,
|
|
68
|
+
amountUsd: number,
|
|
69
|
+
): Promise<UnifiedCheckoutResult> {
|
|
70
|
+
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
depositAddress,
|
|
74
|
+
displayAmount: `${amountUsd} ${method.token}`,
|
|
75
|
+
amountUsd,
|
|
76
|
+
token: method.token,
|
|
77
|
+
chain: method.chain,
|
|
78
|
+
referenceId: `erc20:${method.chain}:${depositAddress}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleNativeEth(
|
|
83
|
+
deps: UnifiedCheckoutDeps,
|
|
84
|
+
method: PaymentMethodRecord,
|
|
85
|
+
tenant: string,
|
|
86
|
+
amountUsdCents: number,
|
|
87
|
+
amountUsd: number,
|
|
88
|
+
): Promise<UnifiedCheckoutResult> {
|
|
89
|
+
const { priceCents } = await deps.oracle.getPrice("ETH");
|
|
90
|
+
const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
|
|
91
|
+
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
92
|
+
|
|
93
|
+
const divisor = BigInt("1000000000000000000");
|
|
94
|
+
const whole = expectedWei / divisor;
|
|
95
|
+
const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
depositAddress,
|
|
99
|
+
displayAmount: `${whole}.${frac} ETH`,
|
|
100
|
+
amountUsd,
|
|
101
|
+
token: "ETH",
|
|
102
|
+
chain: method.chain,
|
|
103
|
+
referenceId: `eth:${method.chain}:${depositAddress}`,
|
|
104
|
+
priceCents,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleNativeBtc(
|
|
109
|
+
deps: UnifiedCheckoutDeps,
|
|
110
|
+
_method: PaymentMethodRecord,
|
|
111
|
+
tenant: string,
|
|
112
|
+
amountUsdCents: number,
|
|
113
|
+
amountUsd: number,
|
|
114
|
+
): Promise<UnifiedCheckoutResult> {
|
|
115
|
+
const { priceCents } = await deps.oracle.getPrice("BTC");
|
|
116
|
+
const expectedSats = centsToNative(amountUsdCents, priceCents, 8);
|
|
117
|
+
|
|
118
|
+
// BTC address derivation uses btcXpub — import from btc module
|
|
119
|
+
const { deriveBtcAddress } = await import("./btc/address-gen.js");
|
|
120
|
+
if (!deps.btcXpub) throw new Error("BTC payments not configured (no BTC_XPUB)");
|
|
121
|
+
|
|
122
|
+
const maxRetries = 3;
|
|
123
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
124
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
125
|
+
const depositAddress = deriveBtcAddress(deps.btcXpub, derivationIndex, "mainnet");
|
|
126
|
+
const referenceId = `btc:${depositAddress}`;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
130
|
+
referenceId,
|
|
131
|
+
tenantId: tenant,
|
|
132
|
+
amountUsdCents,
|
|
133
|
+
chain: "bitcoin",
|
|
134
|
+
token: "BTC",
|
|
135
|
+
depositAddress,
|
|
136
|
+
derivationIndex,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const btcAmount = Number(expectedSats) / 100_000_000;
|
|
140
|
+
return {
|
|
141
|
+
depositAddress,
|
|
142
|
+
displayAmount: `${btcAmount.toFixed(8)} BTC`,
|
|
143
|
+
amountUsd,
|
|
144
|
+
token: "BTC",
|
|
145
|
+
chain: "bitcoin",
|
|
146
|
+
referenceId,
|
|
147
|
+
priceCents,
|
|
148
|
+
};
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
const code = (err as { code?: string }).code;
|
|
151
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
152
|
+
if (!isConflict || attempt === maxRetries) throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
|
|
160
|
+
async function deriveAndStore(
|
|
161
|
+
deps: UnifiedCheckoutDeps,
|
|
162
|
+
method: PaymentMethodRecord,
|
|
163
|
+
tenant: string,
|
|
164
|
+
amountUsdCents: number,
|
|
165
|
+
): Promise<string> {
|
|
166
|
+
const maxRetries = 3;
|
|
167
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
168
|
+
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
169
|
+
const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
|
|
170
|
+
const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
174
|
+
referenceId,
|
|
175
|
+
tenantId: tenant,
|
|
176
|
+
amountUsdCents,
|
|
177
|
+
chain: method.chain,
|
|
178
|
+
token: method.token,
|
|
179
|
+
depositAddress,
|
|
180
|
+
derivationIndex,
|
|
181
|
+
});
|
|
182
|
+
return depositAddress;
|
|
183
|
+
} catch (err: unknown) {
|
|
184
|
+
const code = (err as { code?: string }).code;
|
|
185
|
+
const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
|
|
186
|
+
if (!isConflict || attempt === maxRetries) throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw new Error("Failed to claim derivation index after retries");
|
|
190
|
+
}
|
package/src/db/schema/crypto.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
|
-
import { index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
2
|
+
import { boolean, index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Crypto payment charges — tracks the lifecycle of each BTCPay invoice.
|
|
@@ -46,6 +46,26 @@ export const watcherCursors = pgTable("watcher_cursors", {
|
|
|
46
46
|
updatedAt: text("updated_at").notNull().default(sql`(now())`),
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Payment method registry — runtime-configurable tokens/chains.
|
|
51
|
+
* Admin inserts a row to enable a new payment method. No deploy needed.
|
|
52
|
+
* Contract addresses are immutable on-chain but configurable here.
|
|
53
|
+
*/
|
|
54
|
+
export const paymentMethods = pgTable("payment_methods", {
|
|
55
|
+
id: text("id").primaryKey(), // "USDC:base", "ETH:base", "BTC:mainnet"
|
|
56
|
+
type: text("type").notNull(), // "stablecoin", "eth", "btc"
|
|
57
|
+
token: text("token").notNull(), // "USDC", "ETH", "BTC"
|
|
58
|
+
chain: text("chain").notNull(), // "base", "ethereum", "bitcoin"
|
|
59
|
+
contractAddress: text("contract_address"), // null for native (ETH, BTC)
|
|
60
|
+
decimals: integer("decimals").notNull(),
|
|
61
|
+
displayName: text("display_name").notNull(),
|
|
62
|
+
enabled: boolean("enabled").notNull().default(true),
|
|
63
|
+
displayOrder: integer("display_order").notNull().default(0),
|
|
64
|
+
rpcUrl: text("rpc_url"), // override per-chain RPC (null = use default)
|
|
65
|
+
confirmations: integer("confirmations").notNull().default(1),
|
|
66
|
+
createdAt: text("created_at").notNull().default(sql`(now())`),
|
|
67
|
+
});
|
|
68
|
+
|
|
49
69
|
/** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
|
|
50
70
|
export const watcherProcessed = pgTable(
|
|
51
71
|
"watcher_processed",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type Docker from "dockerode";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { IBotProfileRepository } from "../bot-profile-repository.js";
|
|
4
|
+
import type { FleetManager } from "../fleet-manager.js";
|
|
5
|
+
import { initFleetUpdater } from "../init-fleet-updater.js";
|
|
6
|
+
import type { IProfileStore } from "../profile-store.js";
|
|
7
|
+
|
|
8
|
+
function mockDocker(): Docker {
|
|
9
|
+
return {} as Docker;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockFleet(): FleetManager {
|
|
13
|
+
return {} as FleetManager;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mockStore(): IProfileStore {
|
|
17
|
+
return {
|
|
18
|
+
list: vi.fn(async () => []),
|
|
19
|
+
get: vi.fn(async () => undefined),
|
|
20
|
+
save: vi.fn(async () => {}),
|
|
21
|
+
delete: vi.fn(async () => {}),
|
|
22
|
+
} as unknown as IProfileStore;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mockRepo(profiles: unknown[] = []): IBotProfileRepository {
|
|
26
|
+
return {
|
|
27
|
+
list: vi.fn(async () => profiles),
|
|
28
|
+
get: vi.fn(async () => null),
|
|
29
|
+
save: vi.fn(async (p: unknown) => p),
|
|
30
|
+
delete: vi.fn(async () => true),
|
|
31
|
+
} as unknown as IBotProfileRepository;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("initFleetUpdater", () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns a handle with all components", async () => {
|
|
40
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
41
|
+
|
|
42
|
+
expect(handle.poller).toBeDefined();
|
|
43
|
+
expect(handle.updater).toBeDefined();
|
|
44
|
+
expect(handle.orchestrator).toBeDefined();
|
|
45
|
+
expect(handle.snapshotManager).toBeDefined();
|
|
46
|
+
expect(handle.stop).toBeTypeOf("function");
|
|
47
|
+
|
|
48
|
+
await handle.stop();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("wires poller.onUpdateAvailable to orchestrator", async () => {
|
|
52
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
53
|
+
|
|
54
|
+
expect(handle.poller.onUpdateAvailable).toBeTypeOf("function");
|
|
55
|
+
|
|
56
|
+
await handle.stop();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("accepts custom strategy config", async () => {
|
|
60
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
61
|
+
strategy: "immediate",
|
|
62
|
+
snapshotDir: "/tmp/snapshots",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(handle.orchestrator).toBeDefined();
|
|
66
|
+
|
|
67
|
+
await handle.stop();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("accepts callbacks", async () => {
|
|
71
|
+
const onBotUpdated = vi.fn();
|
|
72
|
+
const onRolloutComplete = vi.fn();
|
|
73
|
+
|
|
74
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
|
|
75
|
+
onBotUpdated,
|
|
76
|
+
onRolloutComplete,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(handle.orchestrator).toBeDefined();
|
|
80
|
+
|
|
81
|
+
await handle.stop();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("stop() stops the poller", async () => {
|
|
85
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
|
|
86
|
+
|
|
87
|
+
const stopSpy = vi.spyOn(handle.poller, "stop");
|
|
88
|
+
|
|
89
|
+
await handle.stop();
|
|
90
|
+
|
|
91
|
+
expect(stopSpy).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("filters manual-policy bots from updatable profiles", async () => {
|
|
95
|
+
const repo = mockRepo([
|
|
96
|
+
{ id: "b1", updatePolicy: "nightly" },
|
|
97
|
+
{ id: "b2", updatePolicy: "manual" },
|
|
98
|
+
{ id: "b3", updatePolicy: "on-push" },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), repo, {
|
|
102
|
+
strategy: "immediate",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const rolloutResult = await handle.orchestrator.rollout();
|
|
106
|
+
|
|
107
|
+
// b2 (manual) should be filtered out, b1 and b3 included
|
|
108
|
+
expect(rolloutResult.totalBots).toBe(2);
|
|
109
|
+
|
|
110
|
+
await handle.stop();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("uses profileRepo for updatable profiles, not profileStore", async () => {
|
|
114
|
+
const store = mockStore();
|
|
115
|
+
const repo = mockRepo([{ id: "b1", updatePolicy: "nightly" }]);
|
|
116
|
+
|
|
117
|
+
const handle = initFleetUpdater(mockDocker(), mockFleet(), store, repo, {
|
|
118
|
+
strategy: "immediate",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await handle.orchestrator.rollout();
|
|
122
|
+
|
|
123
|
+
// profileRepo.list() was called for updatable profiles
|
|
124
|
+
expect(repo.list).toHaveBeenCalled();
|
|
125
|
+
// profileStore.list() may also be called by ImagePoller — that's expected
|
|
126
|
+
|
|
127
|
+
await handle.stop();
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/fleet/index.ts
CHANGED