@wopr-network/platform-core 1.34.0 → 1.35.1
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/address-gen.d.ts +4 -0
- package/dist/billing/crypto/btc/address-gen.js +44 -0
- package/dist/billing/crypto/oracle/chainlink.js +2 -2
- package/dist/billing/crypto/oracle/types.d.ts +1 -1
- package/dist/billing/crypto/unified-checkout.js +33 -20
- package/dist/email/handlebars-renderer.test.d.ts +1 -0
- package/dist/email/handlebars-renderer.test.js +174 -0
- package/dist/fleet/fleet-notification-listener.test.d.ts +1 -0
- package/dist/fleet/fleet-notification-listener.test.js +244 -0
- package/dist/fleet/init-fleet-updater.d.ts +1 -1
- package/dist/fleet/init-fleet-updater.js +19 -3
- package/dist/fleet/init-fleet-updater.test.d.ts +1 -0
- package/dist/fleet/init-fleet-updater.test.js +186 -0
- package/dist/trpc/admin-fleet-update-router.test.d.ts +1 -0
- package/dist/trpc/admin-fleet-update-router.test.js +164 -0
- package/drizzle/migrations/0012_seed_popular_tokens.sql +12 -0
- package/package.json +1 -1
- package/src/billing/crypto/btc/address-gen.ts +49 -0
- package/src/billing/crypto/oracle/chainlink.ts +3 -3
- package/src/billing/crypto/oracle/types.ts +1 -1
- package/src/billing/crypto/unified-checkout.ts +34 -20
- package/src/email/handlebars-renderer.test.ts +209 -0
- package/src/fleet/fleet-notification-listener.test.ts +322 -0
- package/src/fleet/init-fleet-updater.test.ts +234 -0
- package/src/fleet/init-fleet-updater.ts +22 -4
- package/src/trpc/admin-fleet-update-router.test.ts +201 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests the profile-filtering + onManualTenantsSkipped logic used inside initFleetUpdater.
|
|
3
|
+
*
|
|
4
|
+
* We can't import initFleetUpdater directly because it constructs ImagePoller,
|
|
5
|
+
* ContainerUpdater, etc. which pull in heavy dependencies that cause hangs.
|
|
6
|
+
* Instead, we replicate the getUpdatableProfiles closure from initFleetUpdater
|
|
7
|
+
* and test it in isolation via a RolloutOrchestrator with mock deps.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { RolloutOrchestrator } from "./rollout-orchestrator.js";
|
|
11
|
+
vi.mock("../config/logger.js", () => ({
|
|
12
|
+
logger: {
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
warn: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function makeProfileWithFields(fields) {
|
|
23
|
+
return {
|
|
24
|
+
...fields,
|
|
25
|
+
image: "ghcr.io/org/img:latest",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function makeProfileRepo(profiles) {
|
|
29
|
+
return {
|
|
30
|
+
list: vi.fn().mockResolvedValue(profiles),
|
|
31
|
+
get: vi
|
|
32
|
+
.fn()
|
|
33
|
+
.mockImplementation((id) => Promise.resolve(profiles.find((p) => p.id === id) ?? null)),
|
|
34
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
delete: vi.fn().mockResolvedValue(false),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function makeConfigRepo(configs = {}) {
|
|
39
|
+
return {
|
|
40
|
+
get: vi.fn().mockImplementation((tenantId) => {
|
|
41
|
+
const cfg = configs[tenantId];
|
|
42
|
+
return Promise.resolve(cfg ? { tenantId, ...cfg, updatedAt: Date.now() } : null);
|
|
43
|
+
}),
|
|
44
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
listAutoEnabled: vi.fn().mockResolvedValue([]),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function makeMockUpdater() {
|
|
49
|
+
return {
|
|
50
|
+
updateBot: vi.fn().mockResolvedValue({
|
|
51
|
+
botId: "",
|
|
52
|
+
success: true,
|
|
53
|
+
previousImage: "",
|
|
54
|
+
newImage: "",
|
|
55
|
+
previousDigest: null,
|
|
56
|
+
newDigest: null,
|
|
57
|
+
rolledBack: false,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function makeMockSnapshotManager() {
|
|
62
|
+
return {
|
|
63
|
+
snapshot: vi.fn(),
|
|
64
|
+
restore: vi.fn(),
|
|
65
|
+
delete: vi.fn(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function makeMockStrategy() {
|
|
69
|
+
return {
|
|
70
|
+
nextBatch: vi.fn().mockImplementation((remaining) => remaining),
|
|
71
|
+
pauseDuration: vi.fn().mockReturnValue(0),
|
|
72
|
+
onBotFailure: vi.fn().mockReturnValue("skip"),
|
|
73
|
+
maxRetries: vi.fn().mockReturnValue(0),
|
|
74
|
+
healthCheckTimeout: vi.fn().mockReturnValue(0),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds the same getUpdatableProfiles closure that initFleetUpdater creates,
|
|
79
|
+
* so we can test the filtering + callback logic without importing the heavy module.
|
|
80
|
+
*/
|
|
81
|
+
function buildGetUpdatableProfiles(profileRepo, configRepo, onManualTenantsSkipped, imageTag = "v1.2.3") {
|
|
82
|
+
return async () => {
|
|
83
|
+
const profiles = await profileRepo.list();
|
|
84
|
+
const manualPolicyIds = [];
|
|
85
|
+
const nonManualPolicy = profiles.filter((p) => {
|
|
86
|
+
if (p.updatePolicy === "manual") {
|
|
87
|
+
manualPolicyIds.push(p.tenantId);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
if (!configRepo) {
|
|
93
|
+
if (manualPolicyIds.length > 0 && onManualTenantsSkipped) {
|
|
94
|
+
onManualTenantsSkipped([...new Set(manualPolicyIds)], imageTag);
|
|
95
|
+
}
|
|
96
|
+
return nonManualPolicy;
|
|
97
|
+
}
|
|
98
|
+
const configManualIds = [];
|
|
99
|
+
const results = await Promise.all(nonManualPolicy.map(async (p) => {
|
|
100
|
+
const tenantCfg = await configRepo.get(p.tenantId);
|
|
101
|
+
if (tenantCfg && tenantCfg.mode === "manual") {
|
|
102
|
+
configManualIds.push(p.tenantId);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return p;
|
|
106
|
+
}));
|
|
107
|
+
const allManualIds = [...manualPolicyIds, ...configManualIds];
|
|
108
|
+
if (allManualIds.length > 0 && onManualTenantsSkipped) {
|
|
109
|
+
onManualTenantsSkipped([...new Set(allManualIds)], imageTag);
|
|
110
|
+
}
|
|
111
|
+
return results.filter((p) => p !== null);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Tests
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
describe("initFleetUpdater — onManualTenantsSkipped", () => {
|
|
118
|
+
it("callback fires with tenant IDs of manual-mode tenants (policy-based)", async () => {
|
|
119
|
+
const profiles = [
|
|
120
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-manual", updatePolicy: "manual" }),
|
|
121
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-auto", updatePolicy: "auto" }),
|
|
122
|
+
];
|
|
123
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
124
|
+
const onManualTenantsSkipped = vi.fn();
|
|
125
|
+
const orchestrator = new RolloutOrchestrator({
|
|
126
|
+
updater: makeMockUpdater(),
|
|
127
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
128
|
+
strategy: makeMockStrategy(),
|
|
129
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
130
|
+
});
|
|
131
|
+
await orchestrator.rollout();
|
|
132
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-manual"], "v1.2.3");
|
|
133
|
+
});
|
|
134
|
+
it("callback deduplicates tenant IDs", async () => {
|
|
135
|
+
const profiles = [
|
|
136
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-dup", updatePolicy: "manual" }),
|
|
137
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-dup", updatePolicy: "manual" }),
|
|
138
|
+
makeProfileWithFields({ id: "b3", tenantId: "t-auto", updatePolicy: "auto" }),
|
|
139
|
+
];
|
|
140
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
141
|
+
const onManualTenantsSkipped = vi.fn();
|
|
142
|
+
const orchestrator = new RolloutOrchestrator({
|
|
143
|
+
updater: makeMockUpdater(),
|
|
144
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
145
|
+
strategy: makeMockStrategy(),
|
|
146
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
147
|
+
});
|
|
148
|
+
await orchestrator.rollout();
|
|
149
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-dup"], "v1.2.3");
|
|
150
|
+
});
|
|
151
|
+
it("callback not called when no manual tenants exist", async () => {
|
|
152
|
+
const profiles = [
|
|
153
|
+
makeProfileWithFields({ id: "b1", tenantId: "t1", updatePolicy: "auto" }),
|
|
154
|
+
makeProfileWithFields({ id: "b2", tenantId: "t2", updatePolicy: "auto" }),
|
|
155
|
+
];
|
|
156
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
157
|
+
const onManualTenantsSkipped = vi.fn();
|
|
158
|
+
const orchestrator = new RolloutOrchestrator({
|
|
159
|
+
updater: makeMockUpdater(),
|
|
160
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
161
|
+
strategy: makeMockStrategy(),
|
|
162
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, undefined, onManualTenantsSkipped),
|
|
163
|
+
});
|
|
164
|
+
await orchestrator.rollout();
|
|
165
|
+
expect(onManualTenantsSkipped).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
it("callback fires with config-repo manual tenants when configRepo is provided", async () => {
|
|
168
|
+
const profiles = [
|
|
169
|
+
makeProfileWithFields({ id: "b1", tenantId: "t-cfg-manual", updatePolicy: "auto" }),
|
|
170
|
+
makeProfileWithFields({ id: "b2", tenantId: "t-cfg-auto", updatePolicy: "auto" }),
|
|
171
|
+
];
|
|
172
|
+
const profileRepo = makeProfileRepo(profiles);
|
|
173
|
+
const configRepo = makeConfigRepo({
|
|
174
|
+
"t-cfg-manual": { mode: "manual", preferredHourUtc: 3 },
|
|
175
|
+
});
|
|
176
|
+
const onManualTenantsSkipped = vi.fn();
|
|
177
|
+
const orchestrator = new RolloutOrchestrator({
|
|
178
|
+
updater: makeMockUpdater(),
|
|
179
|
+
snapshotManager: makeMockSnapshotManager(),
|
|
180
|
+
strategy: makeMockStrategy(),
|
|
181
|
+
getUpdatableProfiles: buildGetUpdatableProfiles(profileRepo, configRepo, onManualTenantsSkipped),
|
|
182
|
+
});
|
|
183
|
+
await orchestrator.rollout();
|
|
184
|
+
expect(onManualTenantsSkipped).toHaveBeenCalledWith(["t-cfg-manual"], "v1.2.3");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
|
|
3
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
|
|
4
|
+
vi.mock("../config/logger.js", () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
error: vi.fn(),
|
|
8
|
+
warn: vi.fn(),
|
|
9
|
+
debug: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function makeMockOrgRepo() {
|
|
16
|
+
return {
|
|
17
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
18
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
22
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(0),
|
|
23
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
24
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
26
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
27
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
31
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function makeOrchestrator(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
isRolling: false,
|
|
37
|
+
rollout: vi.fn().mockResolvedValue({
|
|
38
|
+
totalBots: 0,
|
|
39
|
+
succeeded: 0,
|
|
40
|
+
failed: 0,
|
|
41
|
+
skipped: 0,
|
|
42
|
+
aborted: false,
|
|
43
|
+
alreadyRunning: false,
|
|
44
|
+
results: [],
|
|
45
|
+
}),
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function makeConfigRepo(configs = {}) {
|
|
50
|
+
return {
|
|
51
|
+
get: vi.fn().mockImplementation((tenantId) => Promise.resolve(configs[tenantId] ?? null)),
|
|
52
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
listAutoEnabled: vi.fn().mockResolvedValue(Object.values(configs)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const adminCtx = {
|
|
57
|
+
user: { id: "admin-1", roles: ["platform_admin"] },
|
|
58
|
+
tenantId: undefined,
|
|
59
|
+
};
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tests
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
describe("createAdminFleetUpdateRouter", () => {
|
|
64
|
+
let orchestrator;
|
|
65
|
+
let configRepo;
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
setTrpcOrgMemberRepo(makeMockOrgRepo());
|
|
68
|
+
orchestrator = makeOrchestrator();
|
|
69
|
+
configRepo = makeConfigRepo();
|
|
70
|
+
});
|
|
71
|
+
function makeCaller() {
|
|
72
|
+
const fleetRouter = createAdminFleetUpdateRouter(() => orchestrator, () => configRepo);
|
|
73
|
+
const appRouter = router({ fleet: fleetRouter });
|
|
74
|
+
const createCaller = createCallerFactory(appRouter);
|
|
75
|
+
return createCaller(adminCtx);
|
|
76
|
+
}
|
|
77
|
+
describe("rolloutStatus", () => {
|
|
78
|
+
it("returns isRolling from orchestrator (false)", async () => {
|
|
79
|
+
const caller = makeCaller();
|
|
80
|
+
const status = await caller.fleet.rolloutStatus();
|
|
81
|
+
expect(status).toEqual({ isRolling: false });
|
|
82
|
+
});
|
|
83
|
+
it("returns isRolling from orchestrator (true)", async () => {
|
|
84
|
+
orchestrator = makeOrchestrator({ isRolling: true });
|
|
85
|
+
const caller = makeCaller();
|
|
86
|
+
const status = await caller.fleet.rolloutStatus();
|
|
87
|
+
expect(status).toEqual({ isRolling: true });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("forceRollout", () => {
|
|
91
|
+
it("calls orchestrator.rollout()", async () => {
|
|
92
|
+
const caller = makeCaller();
|
|
93
|
+
const result = await caller.fleet.forceRollout();
|
|
94
|
+
expect(result).toEqual({ triggered: true });
|
|
95
|
+
expect(orchestrator.rollout).toHaveBeenCalledOnce();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("listTenantConfigs", () => {
|
|
99
|
+
it("delegates to repo.listAutoEnabled()", async () => {
|
|
100
|
+
const configs = [
|
|
101
|
+
{ tenantId: "t1", mode: "auto", preferredHourUtc: 3, updatedAt: Date.now() },
|
|
102
|
+
{ tenantId: "t2", mode: "auto", preferredHourUtc: 12, updatedAt: Date.now() },
|
|
103
|
+
];
|
|
104
|
+
vi.mocked(configRepo.listAutoEnabled).mockResolvedValue(configs);
|
|
105
|
+
const caller = makeCaller();
|
|
106
|
+
const result = await caller.fleet.listTenantConfigs();
|
|
107
|
+
expect(configRepo.listAutoEnabled).toHaveBeenCalledOnce();
|
|
108
|
+
expect(result).toEqual(configs);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("setTenantConfig", () => {
|
|
112
|
+
it("preserves existing preferredHourUtc when not provided", async () => {
|
|
113
|
+
const existing = {
|
|
114
|
+
tenantId: "t1",
|
|
115
|
+
mode: "auto",
|
|
116
|
+
preferredHourUtc: 17,
|
|
117
|
+
updatedAt: Date.now(),
|
|
118
|
+
};
|
|
119
|
+
vi.mocked(configRepo.get).mockResolvedValue(existing);
|
|
120
|
+
const caller = makeCaller();
|
|
121
|
+
await caller.fleet.setTenantConfig({ tenantId: "t1", mode: "manual" });
|
|
122
|
+
expect(configRepo.get).toHaveBeenCalledWith("t1");
|
|
123
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
|
|
124
|
+
mode: "manual",
|
|
125
|
+
preferredHourUtc: 17, // preserved from existing
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it("uses provided preferredHourUtc when given", async () => {
|
|
129
|
+
const existing = {
|
|
130
|
+
tenantId: "t1",
|
|
131
|
+
mode: "auto",
|
|
132
|
+
preferredHourUtc: 17,
|
|
133
|
+
updatedAt: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
vi.mocked(configRepo.get).mockResolvedValue(existing);
|
|
136
|
+
const caller = makeCaller();
|
|
137
|
+
await caller.fleet.setTenantConfig({
|
|
138
|
+
tenantId: "t1",
|
|
139
|
+
mode: "auto",
|
|
140
|
+
preferredHourUtc: 9,
|
|
141
|
+
});
|
|
142
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t1", {
|
|
143
|
+
mode: "auto",
|
|
144
|
+
preferredHourUtc: 9,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
it("defaults to 3 when no existing config and preferredHourUtc not provided", async () => {
|
|
148
|
+
vi.mocked(configRepo.get).mockResolvedValue(null);
|
|
149
|
+
const caller = makeCaller();
|
|
150
|
+
await caller.fleet.setTenantConfig({ tenantId: "t-new", mode: "auto" });
|
|
151
|
+
expect(configRepo.upsert).toHaveBeenCalledWith("t-new", {
|
|
152
|
+
mode: "auto",
|
|
153
|
+
preferredHourUtc: 3, // default
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it("rejects non-admin users", async () => {
|
|
158
|
+
const fleetRouter = createAdminFleetUpdateRouter(() => orchestrator, () => configRepo);
|
|
159
|
+
const appRouter = router({ fleet: fleetRouter });
|
|
160
|
+
const createCaller = createCallerFactory(appRouter);
|
|
161
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
162
|
+
await expect(caller.fleet.rolloutStatus()).rejects.toThrow("Platform admin role required");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Verified Base mainnet contract addresses (source: basescan.org)
|
|
2
|
+
INSERT INTO "payment_methods" ("id", "type", "token", "chain", "contract_address", "decimals", "display_name", "display_order", "confirmations") VALUES
|
|
3
|
+
('WETH:base', 'erc20', 'WETH', 'base', '0x4200000000000000000000000000000000000006', 18, 'Wrapped ETH on Base', 4, 1),
|
|
4
|
+
('cbBTC:base', 'erc20', 'cbBTC', 'base', '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', 8, 'Coinbase BTC on Base', 5, 1),
|
|
5
|
+
('AERO:base', 'erc20', 'AERO', 'base', '0x940181a94A35A4569E4529A3CDfB74e38FD98631', 18, 'Aerodrome on Base', 6, 1),
|
|
6
|
+
('LINK:base', 'erc20', 'LINK', 'base', '0x88Fb150BDc53A65fe94Dea0c9BA0a6dAf8C6e196', 18, 'Chainlink on Base', 7, 1),
|
|
7
|
+
('UNI:base', 'erc20', 'UNI', 'base', '0xc3De830EA07524a0761646a6a4e4be0e114a3C83', 18, 'Uniswap on Base', 8, 1),
|
|
8
|
+
('LTC:litecoin', 'native', 'LTC', 'litecoin', NULL, 8, 'Litecoin', 9, 6),
|
|
9
|
+
('DOGE:dogecoin', 'native', 'DOGE', 'dogecoin', NULL, 8, 'Dogecoin', 10, 6)
|
|
10
|
+
ON CONFLICT ("id") DO NOTHING;
|
|
11
|
+
-- NOTE: PEPE, SHIB, RENDER not seeded — unverified Base contract addresses.
|
|
12
|
+
-- Add via admin panel after verifying contracts on basescan.org.
|
package/package.json
CHANGED
|
@@ -55,6 +55,55 @@ export function deriveTreasury(xpub: string, network: UtxoNetwork = "mainnet", c
|
|
|
55
55
|
return bech32.encode(prefix, [0, ...words]);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** P2PKH version bytes for Base58Check encoding (chains without bech32). */
|
|
59
|
+
const P2PKH_VERSION: Record<string, Record<string, number>> = {
|
|
60
|
+
dogecoin: { mainnet: 0x1e, testnet: 0x71 },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
64
|
+
function base58encode(data: Uint8Array): string {
|
|
65
|
+
let num = 0n;
|
|
66
|
+
for (const byte of data) num = num * 256n + BigInt(byte);
|
|
67
|
+
let encoded = "";
|
|
68
|
+
while (num > 0n) {
|
|
69
|
+
encoded = BASE58_ALPHABET[Number(num % 58n)] + encoded;
|
|
70
|
+
num = num / 58n;
|
|
71
|
+
}
|
|
72
|
+
for (const byte of data) {
|
|
73
|
+
if (byte !== 0) break;
|
|
74
|
+
encoded = "1" + encoded;
|
|
75
|
+
}
|
|
76
|
+
return encoded;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive a P2PKH (Base58Check) address for chains without bech32 (e.g., DOGE → D...).
|
|
81
|
+
*/
|
|
82
|
+
export function deriveP2pkhAddress(
|
|
83
|
+
xpub: string,
|
|
84
|
+
index: number,
|
|
85
|
+
chain: string,
|
|
86
|
+
network: "mainnet" | "testnet" = "mainnet",
|
|
87
|
+
): string {
|
|
88
|
+
if (!Number.isInteger(index) || index < 0) throw new Error(`Invalid derivation index: ${index}`);
|
|
89
|
+
const version = P2PKH_VERSION[chain]?.[network];
|
|
90
|
+
if (version === undefined) throw new Error(`No P2PKH version for chain=${chain} network=${network}`);
|
|
91
|
+
|
|
92
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
93
|
+
const child = master.deriveChild(0).deriveChild(index);
|
|
94
|
+
if (!child.publicKey) throw new Error("Failed to derive public key");
|
|
95
|
+
|
|
96
|
+
const hash160 = ripemd160(sha256(child.publicKey));
|
|
97
|
+
const payload = new Uint8Array(21);
|
|
98
|
+
payload[0] = version;
|
|
99
|
+
payload.set(hash160, 1);
|
|
100
|
+
const checksum = sha256(sha256(payload));
|
|
101
|
+
const full = new Uint8Array(25);
|
|
102
|
+
full.set(payload);
|
|
103
|
+
full.set(checksum.slice(0, 4), 21);
|
|
104
|
+
return base58encode(full);
|
|
105
|
+
}
|
|
106
|
+
|
|
58
107
|
/** @deprecated Use `deriveAddress` instead. */
|
|
59
108
|
export const deriveBtcAddress = deriveAddress;
|
|
60
109
|
|
|
@@ -36,17 +36,17 @@ export interface ChainlinkOracleOpts {
|
|
|
36
36
|
*/
|
|
37
37
|
export class ChainlinkOracle implements IPriceOracle {
|
|
38
38
|
private readonly rpc: RpcCall;
|
|
39
|
-
private readonly feeds:
|
|
39
|
+
private readonly feeds: Map<string, `0x${string}`>;
|
|
40
40
|
private readonly maxStalenessMs: number;
|
|
41
41
|
|
|
42
42
|
constructor(opts: ChainlinkOracleOpts) {
|
|
43
43
|
this.rpc = opts.rpcCall;
|
|
44
|
-
this.feeds = { ...FEED_ADDRESSES, ...opts.feedAddresses }
|
|
44
|
+
this.feeds = new Map(Object.entries({ ...FEED_ADDRESSES, ...opts.feedAddresses })) as Map<string, `0x${string}`>;
|
|
45
45
|
this.maxStalenessMs = opts.maxStalenessMs ?? DEFAULT_MAX_STALENESS_MS;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async getPrice(asset: PriceAsset): Promise<PriceResult> {
|
|
49
|
-
const feedAddress = this.feeds
|
|
49
|
+
const feedAddress = this.feeds.get(asset);
|
|
50
50
|
if (!feedAddress) throw new Error(`No price feed for asset: ${asset}`);
|
|
51
51
|
|
|
52
52
|
const result = (await this.rpc("eth_call", [{ to: feedAddress, data: LATEST_ROUND_DATA }, "latest"])) as string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Credit } from "../../credits/credit.js";
|
|
2
|
+
import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
|
|
2
3
|
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
3
4
|
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
4
5
|
import { centsToNative } from "./oracle/convert.js";
|
|
@@ -50,11 +51,11 @@ export async function createUnifiedCheckout(
|
|
|
50
51
|
if (method.type === "erc20") {
|
|
51
52
|
return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
52
53
|
}
|
|
53
|
-
if (method.
|
|
54
|
-
return
|
|
54
|
+
if (method.type === "native" && method.chain === "base") {
|
|
55
|
+
return handleNativeEvm(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
55
56
|
}
|
|
56
|
-
if (method.
|
|
57
|
-
return
|
|
57
|
+
if (method.type === "native") {
|
|
58
|
+
return handleNativeUtxo(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
|
|
@@ -79,7 +80,7 @@ async function handleErc20(
|
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
async function
|
|
83
|
+
async function handleNativeEvm(
|
|
83
84
|
deps: UnifiedCheckoutDeps,
|
|
84
85
|
method: PaymentMethodRecord,
|
|
85
86
|
tenant: string,
|
|
@@ -105,44 +106,57 @@ async function handleNativeEth(
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Handle native UTXO coins (BTC, LTC, DOGE, BCH, etc.).
|
|
111
|
+
* Uses the xpub from the payment method record (DB-driven).
|
|
112
|
+
* Derives bech32 addresses for BTC/LTC, Base58 P2PKH for DOGE.
|
|
113
|
+
*/
|
|
114
|
+
async function handleNativeUtxo(
|
|
109
115
|
deps: UnifiedCheckoutDeps,
|
|
110
|
-
|
|
116
|
+
method: PaymentMethodRecord,
|
|
111
117
|
tenant: string,
|
|
112
118
|
amountUsdCents: number,
|
|
113
119
|
amountUsd: number,
|
|
114
120
|
): Promise<UnifiedCheckoutResult> {
|
|
115
|
-
const
|
|
116
|
-
|
|
121
|
+
const xpub = method.xpub ?? deps.btcXpub;
|
|
122
|
+
if (!xpub) throw new Error(`${method.token} payments not configured (no xpub)`);
|
|
117
123
|
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
if (!deps.btcXpub) throw new Error("BTC payments not configured (no BTC_XPUB)");
|
|
124
|
+
const { priceCents } = await deps.oracle.getPrice(method.token);
|
|
125
|
+
const rawAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
|
|
121
126
|
|
|
122
127
|
const maxRetries = 3;
|
|
123
128
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
124
129
|
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
|
|
131
|
+
// Derive address by chain type
|
|
132
|
+
let depositAddress: string;
|
|
133
|
+
if (method.chain === "dogecoin") {
|
|
134
|
+
depositAddress = deriveP2pkhAddress(xpub, derivationIndex, "dogecoin");
|
|
135
|
+
} else {
|
|
136
|
+
depositAddress = deriveAddress(xpub, derivationIndex, "mainnet", method.chain as "bitcoin" | "litecoin");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const referenceId = `${method.token.toLowerCase()}:${depositAddress}`;
|
|
127
140
|
|
|
128
141
|
try {
|
|
129
142
|
await deps.chargeStore.createStablecoinCharge({
|
|
130
143
|
referenceId,
|
|
131
144
|
tenantId: tenant,
|
|
132
145
|
amountUsdCents,
|
|
133
|
-
chain:
|
|
134
|
-
token:
|
|
146
|
+
chain: method.chain,
|
|
147
|
+
token: method.token,
|
|
135
148
|
depositAddress,
|
|
136
149
|
derivationIndex,
|
|
137
150
|
});
|
|
138
151
|
|
|
139
|
-
const
|
|
152
|
+
const divisor = 10 ** method.decimals;
|
|
153
|
+
const displayAmt = (Number(rawAmount) / divisor).toFixed(method.decimals);
|
|
140
154
|
return {
|
|
141
155
|
depositAddress,
|
|
142
|
-
displayAmount: `${
|
|
156
|
+
displayAmount: `${displayAmt} ${method.token}`,
|
|
143
157
|
amountUsd,
|
|
144
|
-
token:
|
|
145
|
-
chain:
|
|
158
|
+
token: method.token,
|
|
159
|
+
chain: method.chain,
|
|
146
160
|
referenceId,
|
|
147
161
|
priceCents,
|
|
148
162
|
};
|