-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
483 lines (415 loc) · 17.9 KB
/
Copy pathserver.ts
File metadata and controls
483 lines (415 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
import express from "express";
import { ethers } from "ethers";
import "dotenv/config";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Config ──────────────────────────────────────────────────────────────────
const SPRINTER_API = "https://api.sprinter.tech";
const USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
const USDC_DECIMALS = 6;
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const RPC_URL = process.env.RPC_URL || "https://mainnet.base.org";
const DEMO_AMOUNT = parseFloat(process.env.DEMO_AMOUNT || "1.00");
const DRY_RUN = process.env.DRY_RUN === "true";
const PORT = parseInt(process.env.PORT || "3002");
if (!PRIVATE_KEY) {
console.error("\n❌ PRIVATE_KEY not set. Copy .env.example to .env and fill it in.\n");
process.exit(1);
}
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const account = wallet.address;
const usdcUnits = (amount: number) =>
BigInt(Math.round(amount * 10 ** USDC_DECIMALS)).toString();
interface ContractCall {
to: string;
data: string;
value: string;
chain: string;
}
// ── SSE Event Stream ────────────────────────────────────────────────────────
type SSEWriter = (event: string, data: any) => void;
function createSSEWriter(res: express.Response): SSEWriter {
return (event: string, data: any) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
}
// ── API Helpers ─────────────────────────────────────────────────────────────
async function sprinterGet(path: string, send: SSEWriter): Promise<any> {
const url = `${SPRINTER_API}${path}`;
send("log", { message: `GET ${path}` });
const res = await fetch(url);
if (!res.ok) {
const body = await res.text();
if (DRY_RUN) {
send("log", { message: `API returned ${res.status} (expected in dry run)`, type: "warn" });
return { calls: [{ to: "0x0", data: "0x0", value: "0", chain: "eip155:8453" }] };
}
throw new Error(`Sprinter API ${res.status}: ${body}`);
}
return res.json();
}
async function executeCalls(calls: ContractCall[], send: SSEWriter): Promise<string> {
if (DRY_RUN) {
send("log", { message: `DRY RUN — would execute ${calls.length} transaction(s)`, type: "info" });
return "0x_dry_run";
}
// Filter out any USDC approve calls from the API response — we handle approval ourselves
const filteredCalls = calls.filter((call) => {
if (call.to.toLowerCase() === USDC_ADDRESS.toLowerCase() && call.data.startsWith("0x095ea7b3")) {
send("log", { message: `Skipping API approve call (handled separately)` });
return false;
}
return true;
});
let lastTxHash = "";
for (let i = 0; i < filteredCalls.length; i++) {
const call = filteredCalls[i];
send("log", { message: `Sending tx ${i + 1}/${filteredCalls.length} to ${call.to}` });
const nonce = await wallet.getNonce("pending");
send("log", { message: `Using nonce ${nonce}` });
const feeData = await provider.getFeeData();
const gasEstimate = await wallet.estimateGas({
to: call.to,
data: call.data,
value: call.value || "0",
});
const tx = await wallet.sendTransaction({
to: call.to,
data: call.data,
value: call.value || "0",
gasLimit: (gasEstimate * 120n) / 100n,
nonce,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
});
send("log", { message: `Waiting for confirmation... ${tx.hash}` });
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error(`Transaction reverted: ${tx.hash}`);
}
send("log", { message: `Confirmed in block ${receipt.blockNumber}` });
lastTxHash = tx.hash;
}
return lastTxHash;
}
async function getUsdcBalance(): Promise<string> {
const usdc = new ethers.Contract(
USDC_ADDRESS,
["function balanceOf(address) view returns (uint256)"],
provider
);
const balance = await usdc.balanceOf(account);
return ethers.formatUnits(balance, USDC_DECIMALS);
}
async function getEthBalance(): Promise<string> {
const balance = await provider.getBalance(account);
return ethers.formatEther(balance);
}
async function ensureUsdcApproval(spender: string, amount: string, send: SSEWriter): Promise<void> {
const usdc = new ethers.Contract(
USDC_ADDRESS,
[
"function allowance(address owner, address spender) view returns (uint256)",
"function approve(address spender, uint256 amount) returns (bool)",
],
wallet
);
const currentAllowance = await usdc.allowance(account, spender);
const needed = BigInt(amount);
if (currentAllowance >= needed) {
send("log", { message: `USDC allowance sufficient (${currentAllowance} >= ${needed})` });
return;
}
send("log", { message: `Approving USDC spend for ${spender}...` });
const tx = await usdc.approve(spender, ethers.MaxUint256);
send("log", { message: `Waiting for approval confirmation... ${tx.hash}` });
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error(`USDC approval reverted: ${tx.hash}`);
}
send("log", { message: `USDC approved in block ${receipt.blockNumber}` });
}
// ── Express App ─────────────────────────────────────────────────────────────
const app = express();
app.use(express.static(join(__dirname, "public")));
// GET /api/status — wallet info and balances
app.get("/api/status", async (_req, res) => {
try {
const [usdcBalance, ethBalance, health] = await Promise.all([
getUsdcBalance(),
getEthBalance(),
fetch(`${SPRINTER_API}/health`).then((r) => r.json()),
]);
res.json({
account,
rpc: RPC_URL,
demoAmount: DEMO_AMOUNT,
drawAmount: DEMO_AMOUNT * 0.5,
dryRun: DRY_RUN,
usdcBalance,
ethBalance,
apiStatus: health.status,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// GET /api/recover — repay any outstanding debt and unlock stuck collateral
app.get("/api/recover", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const send = createSSEWriter(res);
try {
send("log", { message: "Checking credit position..." });
const infoData = await sprinterGet(`/credit/accounts/${account}/info`, send);
const usdc = infoData.data.usdc;
const debt = parseFloat(usdc.debt);
const collateral = parseFloat(usdc.totalCollateralValue);
send("log", { message: `Debt: ${debt.toFixed(6)} USDC, Collateral: ${collateral.toFixed(6)} USDC` });
if (debt < 0.000001 && collateral < 0.000001) {
send("complete", { message: "Nothing to recover — no debt or collateral found." });
res.end();
return;
}
// Repay any outstanding debt first
if (debt > 0.001) {
send("step", { step: 0, status: "running", title: `Repay ${debt.toFixed(6)} USDC debt` });
const repayUnits = BigInt(Math.ceil(debt * 10 ** USDC_DECIMALS) + 2).toString();
const repayData = await sprinterGet(
`/credit/accounts/${account}/repay?amount=${repayUnits}`,
send
);
const repayContract = repayData.calls.find((c: ContractCall) => c.to.toLowerCase() !== USDC_ADDRESS.toLowerCase());
if (repayContract) {
await ensureUsdcApproval(repayContract.to, repayUnits, send);
}
const repayTx = await executeCalls(repayData.calls, send);
send("step", { step: 0, status: "done", title: `Repaid debt`, txHash: repayTx });
}
// Check for remaining dust debt
const postRepayInfo = await sprinterGet(`/credit/accounts/${account}/info`, send);
const postDebt = parseFloat(postRepayInfo.data.usdc.debt);
if (postDebt > 0.0001) {
send("log", { message: `Dust debt remaining: ${postDebt.toFixed(6)} — repaying...` });
const dustUnits = BigInt(Math.ceil(postDebt * 10 ** USDC_DECIMALS) + 2).toString();
const dustData = await sprinterGet(`/credit/accounts/${account}/repay?amount=${dustUnits}`, send);
const dustContract = dustData.calls.find((c: ContractCall) => c.to.toLowerCase() !== USDC_ADDRESS.toLowerCase());
if (dustContract) {
await ensureUsdcApproval(dustContract.to, dustUnits, send);
}
await executeCalls(dustData.calls, send);
}
// Unlock collateral
const postInfo = await sprinterGet(`/credit/accounts/${account}/info`, send);
const postCollateral = parseFloat(postInfo.data.usdc.totalCollateralValue);
if (postCollateral > 0.001) {
send("step", { step: 1, status: "running", title: `Unlock ${postCollateral.toFixed(2)} USDC collateral` });
const unlockUnits = BigInt(Math.round(postCollateral * 10 ** USDC_DECIMALS)).toString();
const unlockData = await sprinterGet(
`/credit/accounts/${account}/unlock?amount=${unlockUnits}&asset=${USDC_ADDRESS}`,
send
);
const unlockTx = await executeCalls(unlockData.calls, send);
send("step", { step: 1, status: "done", title: `Unlocked collateral`, txHash: unlockTx });
}
send("complete", { message: "Recovery complete. Collateral unlocked." });
} catch (err: any) {
send("error", { message: err.message });
}
res.end();
});
// GET /api/run — execute the demo via Server-Sent Events
app.get("/api/run", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const send = createSSEWriter(res);
try {
// Step 0: Protocol config
send("step", { step: 0, status: "running", title: "API Health Check" });
const health = await sprinterGet("/health", send);
const protocol = await sprinterGet("/credit/protocol", send);
const chainConfig = protocol.chains?.["eip155:8453"];
const usdcLtv = chainConfig?.collateral?.[`erc20:${USDC_ADDRESS}`]?.ltv;
const strategies = Object.keys(chainConfig?.strategies || {});
send("step", {
step: 0,
status: "done",
title: "API Health Check",
details: {
apiStatus: health.status,
chain: "Base (eip155:8453)",
usdcLtv: usdcLtv ? `${parseInt(usdcLtv) / 100}%` : "N/A",
strategies: strategies.join(", "),
},
});
// Step 1: Lock collateral
send("step", { step: 1, status: "running", title: `Lock ${DEMO_AMOUNT} USDC` });
const lockData = await sprinterGet(
`/credit/accounts/${account}/lock?amount=${usdcUnits(DEMO_AMOUNT)}&asset=${USDC_ADDRESS}`,
send
);
// Ensure USDC is approved for the lock contract before executing
const lockContract = lockData.calls.find((c: ContractCall) => c.to.toLowerCase() !== USDC_ADDRESS.toLowerCase());
if (lockContract) {
await ensureUsdcApproval(lockContract.to, usdcUnits(DEMO_AMOUNT), send);
}
const lockTx = await executeCalls(lockData.calls, send);
send("step", {
step: 1,
status: "done",
title: `Lock ${DEMO_AMOUNT} USDC`,
txHash: lockTx,
details: { transactions: lockData.calls.length },
});
// Step 2: Check credit
send("step", { step: 2, status: "running", title: "Check Credit Line" });
const infoData = await sprinterGet(`/credit/accounts/${account}/info`, send);
const usdc = infoData.data.usdc;
let hf = usdc.healthFactor;
if (parseFloat(hf) > 1000000) hf = "∞";
else hf = parseFloat(hf).toFixed(2);
const remainingCapacity = parseFloat(usdc.remainingCreditCapacity);
const existingDebt = parseFloat(usdc.debt);
send("step", {
step: 2,
status: "done",
title: "Check Credit Line",
details: {
creditCapacity: `${parseFloat(usdc.totalCreditCapacity).toFixed(2)} USDC`,
remainingCapacity: `${remainingCapacity.toFixed(2)} USDC`,
collateralValue: `${parseFloat(usdc.totalCollateralValue).toFixed(2)} USDC`,
healthFactor: hf,
debt: `${existingDebt.toFixed(2)} USDC`,
},
});
// Calculate draw amount based on actual remaining capacity (use 50% of capacity, leave room)
const drawAmount = Math.min(DEMO_AMOUNT * 0.5, Math.floor(remainingCapacity * 0.5 * 100) / 100);
if (drawAmount < 0.01) {
throw new Error(`Insufficient credit capacity to draw. Remaining: ${remainingCapacity.toFixed(6)} USDC. Try "Recover Funds" first to clear any stuck debt.`);
}
// Step 3: Draw (card swipe)
send("step", {
step: 3,
status: "running",
title: `Card Swipe — Draw $${drawAmount.toFixed(2)}`,
});
const drawData = await sprinterGet(
`/credit/accounts/${account}/draw?amount=${usdcUnits(drawAmount)}&receiver=${account}`,
send
);
const drawTx = await executeCalls(drawData.calls, send);
// Re-check credit after draw
const postDrawInfo = await sprinterGet(`/credit/accounts/${account}/info`, send);
const postDraw = postDrawInfo.data.usdc;
let postHf = postDraw.healthFactor;
if (parseFloat(postHf) > 1000000) postHf = "∞";
else postHf = parseFloat(postHf).toFixed(2);
send("step", {
step: 3,
status: "done",
title: `Card Swipe — Draw $${drawAmount.toFixed(2)}`,
txHash: drawTx,
details: {
drawn: `${drawAmount.toFixed(2)} USDC`,
merchant: "Demo Coffee Shop",
remainingCapacity: `${parseFloat(postDraw.remainingCreditCapacity).toFixed(2)} USDC`,
debt: `${parseFloat(postDraw.debt).toFixed(2)} USDC`,
healthFactor: postHf,
},
});
// Step 4: Repay all outstanding debt
// Get current total debt (includes accrued interest + any prior residual debt)
const preRepayInfo = await sprinterGet(`/credit/accounts/${account}/info`, send);
const totalDebt = parseFloat(preRepayInfo.data.usdc.debt);
// Add a small buffer to cover interest that accrues between the info call and the repay tx
const repayAmount = Math.ceil((totalDebt + 0.01) * 10 ** USDC_DECIMALS);
const repayUnits = BigInt(repayAmount).toString();
send("step", { step: 4, status: "running", title: `Repay $${totalDebt.toFixed(2)} debt` });
const repayData = await sprinterGet(
`/credit/accounts/${account}/repay?amount=${repayUnits}`,
send
);
const repayContract = repayData.calls.find((c: ContractCall) => c.to.toLowerCase() !== USDC_ADDRESS.toLowerCase());
if (repayContract) {
await ensureUsdcApproval(repayContract.to, repayUnits, send);
}
const repayTx = await executeCalls(repayData.calls, send);
// Verify debt is cleared
const postRepayInfo = await sprinterGet(`/credit/accounts/${account}/info`, send);
const postRepay = postRepayInfo.data.usdc;
const remainingDebt = parseFloat(postRepay.debt);
// If there's still dust debt, do one more repay
if (remainingDebt > 0.0001) {
send("log", { message: `Dust debt remaining: ${remainingDebt.toFixed(6)} USDC — clearing...`, type: "info" });
const dustUnits = BigInt(Math.ceil(remainingDebt * 10 ** USDC_DECIMALS) + 10).toString();
const dustRepay = await sprinterGet(
`/credit/accounts/${account}/repay?amount=${dustUnits}`,
send
);
const dustContract = dustRepay.calls.find((c: ContractCall) => c.to.toLowerCase() !== USDC_ADDRESS.toLowerCase());
if (dustContract) {
await ensureUsdcApproval(dustContract.to, dustUnits, send);
}
await executeCalls(dustRepay.calls, send);
}
const finalDebtInfo = await sprinterGet(`/credit/accounts/${account}/info`, send);
const finalDebt = finalDebtInfo.data.usdc;
send("step", {
step: 4,
status: "done",
title: `Repay $${totalDebt.toFixed(2)} debt`,
txHash: repayTx,
details: {
repaid: `${totalDebt.toFixed(2)} USDC`,
remainingDebt: `${parseFloat(finalDebt.debt).toFixed(6)} USDC`,
},
});
// Step 5: Unlock
send("step", { step: 5, status: "running", title: `Unlock ${DEMO_AMOUNT} USDC` });
const unlockData = await sprinterGet(
`/credit/accounts/${account}/unlock?amount=${usdcUnits(DEMO_AMOUNT)}&asset=${USDC_ADDRESS}`,
send
);
const unlockTx = await executeCalls(unlockData.calls, send);
const finalUsdc = await getUsdcBalance();
const finalEth = await getEthBalance();
send("step", {
step: 5,
status: "done",
title: `Unlock ${DEMO_AMOUNT} USDC`,
txHash: unlockTx,
details: {
finalUsdcBalance: `${finalUsdc} USDC`,
finalEthBalance: `${finalEth} ETH`,
},
});
// Done
send("complete", {
transactions: {
lock: lockTx,
draw: drawTx,
repay: repayTx,
unlock: unlockTx,
},
});
} catch (err: any) {
send("error", { message: err.message });
}
res.end();
});
app.listen(PORT, () => {
console.log();
console.log("╔══════════════════════════════════════════════════════════╗");
console.log("║ Sprinter Credit — Credit Draw Demo ║");
console.log("╚══════════════════════════════════════════════════════════╝");
console.log();
console.log(` 🌐 Open http://localhost:${PORT} in your browser`);
console.log(` 🔑 Wallet: ${account}`);
console.log(` 💵 Amount: ${DEMO_AMOUNT} USDC`);
if (DRY_RUN) console.log(" 🏃 DRY RUN MODE — no on-chain transactions");
console.log();
});