@subly_fi/pay 0.3.0 → 0.4.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/pay.js CHANGED
@@ -1,3 +1,7 @@
1
+ // src/pay.ts
2
+ import { homedir } from "node:os";
3
+ import { join as join2 } from "node:path";
4
+
1
5
  // ../../src/client/agent-wallet-signer.ts
2
6
  import { signBytes } from "@solana/kit";
3
7
  import bs584 from "bs58";
@@ -19,6 +23,7 @@ import {
19
23
 
20
24
  // ../../src/lib/hash.ts
21
25
  import { createHash } from "node:crypto";
26
+ var EMPTY_BODY_HASH = "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
22
27
  function sha256TaggedHex(data) {
23
28
  return `sha256-${createHash("sha256").update(data).digest("hex")}`;
24
29
  }
@@ -115,12 +120,12 @@ function deriveAssociatedTokenAddress(params) {
115
120
  "associatedTokenProgramId"
116
121
  );
117
122
  for (let bump = 255; bump >= 0; bump -= 1) {
118
- const address3 = createProgramAddress(
123
+ const address2 = createProgramAddress(
119
124
  [owner, tokenProgramId, mint, Uint8Array.of(bump)],
120
125
  associatedTokenProgramId
121
126
  );
122
- if (address3 !== null) {
123
- return bs582.encode(address3);
127
+ if (address2 !== null) {
128
+ return bs582.encode(address2);
124
129
  }
125
130
  }
126
131
  throw new Error("Unable to derive associated token account address");
@@ -1036,9 +1041,6 @@ async function ensureWalletOnboarded(params) {
1036
1041
  await post("sync", `/v1/wallets/${wallet}/sync`, { source: "chain" });
1037
1042
  }
1038
1043
 
1039
- // ../../src/client/relayer-yield-realizer.ts
1040
- import { address as address2 } from "@solana/kit";
1041
-
1042
1044
  // ../../src/client/lookup-tables.ts
1043
1045
  import { fetchAllMaybeAddressLookupTable } from "@solana-program/address-lookup-table";
1044
1046
  import { address, getCompiledTransactionMessageDecoder as getCompiledTransactionMessageDecoder2 } from "@solana/kit";
@@ -1079,124 +1081,165 @@ async function fetchLookupTablesForTransaction(rpc2, serializedTransaction) {
1079
1081
  return result;
1080
1082
  }
1081
1083
 
1082
- // ../../src/client/relayer-yield-realizer.ts
1083
- var RelayerRealizeError = class extends Error {
1084
- constructor(code, message, detail = null) {
1084
+ // ../../src/client/vault-flows.ts
1085
+ var VaultFlowClientError = class extends Error {
1086
+ constructor(step, message, detail = null) {
1085
1087
  super(message);
1086
- this.code = code;
1088
+ this.step = step;
1087
1089
  this.detail = detail;
1088
- this.name = "RelayerRealizeError";
1090
+ this.name = "VaultFlowClientError";
1089
1091
  }
1090
- code;
1092
+ step;
1091
1093
  detail;
1092
1094
  };
1093
- var RelayerYieldRealizer = class {
1094
- config;
1095
+ var VaultFlowClient = class {
1096
+ baseUrl;
1095
1097
  signer;
1096
- rpc;
1097
1098
  fetchImpl;
1098
1099
  lookupTablesFor;
1100
+ pollTimeoutMs;
1101
+ pollIntervalMs;
1099
1102
  constructor(config) {
1100
- this.config = {
1101
- facilitatorBaseUrl: config.facilitatorBaseUrl.replace(/\/$/, ""),
1102
- usdcMint: config.usdcMint ?? SUBLY_VAULT.usdcMint,
1103
- forceRealizeFullAmount: config.forceRealizeFullAmount ?? false
1104
- };
1103
+ this.baseUrl = config.facilitatorBaseUrl.replace(/\/$/, "");
1105
1104
  this.signer = config.signer;
1106
- this.rpc = config.rpc;
1107
1105
  this.fetchImpl = config.fetchImpl ?? fetch;
1108
- this.lookupTablesFor = config.lookupTablesFor ?? ((serializedTransaction) => fetchLookupTablesForTransaction(this.rpc, serializedTransaction));
1106
+ this.lookupTablesFor = config.lookupTablesFor ?? ((serializedTransaction) => fetchLookupTablesForTransaction(config.rpc, serializedTransaction));
1107
+ this.pollTimeoutMs = config.pollTimeoutMs ?? 9e4;
1108
+ this.pollIntervalMs = config.pollIntervalMs ?? 2500;
1109
1109
  }
1110
- async ensureUsdcAvailable(input) {
1111
- const agentAta = deriveAssociatedTokenAddress({
1112
- owner: this.signer.walletAddress,
1113
- mint: this.config.usdcMint
1114
- });
1115
- let shortfallRawUsdc;
1116
- if (this.config.forceRealizeFullAmount) {
1117
- shortfallRawUsdc = input.amountRawUsdc;
1118
- } else {
1119
- const currentBalance = await this.readTokenBalance(agentAta);
1120
- if (currentBalance >= input.amountRawUsdc) {
1121
- return { realizedRawUsdc: 0n, txSignature: null };
1122
- }
1123
- shortfallRawUsdc = input.amountRawUsdc - currentBalance;
1124
- }
1125
- await this.assertSpendableYield(shortfallRawUsdc);
1126
- const prepared = await this.postJson("/v1/withdrawals/prepare", {
1110
+ /** Moves USDC from the agent wallet into the vault (fee sponsored). */
1111
+ async deposit(input) {
1112
+ const prepared = await this.postJson("prepare", "/v1/deposits/prepare", {
1127
1113
  wallet: this.signer.walletAddress,
1128
- amountRawUsdc: shortfallRawUsdc.toString()
1114
+ amountRawUsdc: input.amountRawUsdc.toString()
1115
+ });
1116
+ const signed = await this.signer.signDeposit({
1117
+ intent: prepared.signingIntent,
1118
+ serializedTransaction: prepared.serializedTransaction,
1119
+ lookupTables: await this.lookupTablesFor(prepared.serializedTransaction)
1129
1120
  });
1121
+ let outcome = await this.postJson("submit", "/v1/deposits/submit", {
1122
+ depositId: prepared.depositId,
1123
+ serializedTransaction: signed.serializedTransaction,
1124
+ agentSignature: signed.agentSignature
1125
+ });
1126
+ if (outcome.status === "submitted") {
1127
+ outcome = await this.pollUntilTerminal(
1128
+ `/v1/deposits/${prepared.depositId}`,
1129
+ outcome
1130
+ );
1131
+ }
1132
+ return {
1133
+ depositId: prepared.depositId,
1134
+ status: outcome.status,
1135
+ txSignature: outcome.txSignature ?? null,
1136
+ actualDepositRawUsdc: outcome.actualDepositRawUsdc ?? null,
1137
+ sharesMintedRaw: outcome.sharesMintedRaw ?? null,
1138
+ errorCode: outcome.errorCode ?? null
1139
+ };
1140
+ }
1141
+ /**
1142
+ * Moves USDC from the vault back to the agent wallet's USDC ATA (fee
1143
+ * sponsored). A plain withdrawal is the exit path and MAY spend principal;
1144
+ * with purpose "yield_realize" the relayer refuses anything beyond the
1145
+ * spendable yield (the payment path, via RelayerYieldRealizer).
1146
+ */
1147
+ async withdraw(input) {
1148
+ const prepared = await this.postJson(
1149
+ "prepare",
1150
+ "/v1/withdrawals/prepare",
1151
+ {
1152
+ wallet: this.signer.walletAddress,
1153
+ amountRawUsdc: input.amountRawUsdc.toString(),
1154
+ ...input.purpose === void 0 ? {} : { purpose: input.purpose }
1155
+ }
1156
+ );
1130
1157
  const signed = await this.signer.signWithdrawal({
1131
1158
  intent: prepared.signingIntent,
1132
1159
  serializedTransaction: prepared.serializedTransaction,
1133
1160
  lookupTables: await this.lookupTablesFor(prepared.serializedTransaction)
1134
1161
  });
1135
- let settled = await this.postJson("/v1/withdrawals/submit", {
1162
+ let outcome = await this.postJson("submit", "/v1/withdrawals/submit", {
1136
1163
  withdrawalId: prepared.withdrawalId,
1137
1164
  serializedTransaction: signed.serializedTransaction,
1138
1165
  agentSignature: signed.agentSignature
1139
1166
  });
1140
- if (settled.status === "submitted") {
1141
- settled = await this.pollUntilSettled(prepared.withdrawalId);
1142
- }
1143
- if (settled.status !== "confirmed" || settled.txSignature === null) {
1144
- throw new RelayerRealizeError(
1145
- "realize_not_confirmed",
1146
- `yield realize withdrawal did not confirm (status=${settled.status})`,
1147
- settled
1167
+ if (outcome.status === "submitted") {
1168
+ outcome = await this.pollUntilTerminal(
1169
+ `/v1/withdrawals/${prepared.withdrawalId}`,
1170
+ outcome
1148
1171
  );
1149
1172
  }
1150
1173
  return {
1151
- realizedRawUsdc: BigInt(settled.actualWithdrawRawUsdc ?? "0"),
1152
- txSignature: settled.txSignature
1174
+ withdrawalId: prepared.withdrawalId,
1175
+ status: outcome.status,
1176
+ txSignature: outcome.txSignature ?? null,
1177
+ destinationUsdcAta: prepared.destinationUsdcAta,
1178
+ actualWithdrawRawUsdc: outcome.actualWithdrawRawUsdc ?? null,
1179
+ actualSharesBurnedRaw: outcome.actualSharesBurnedRaw ?? null,
1180
+ errorCode: outcome.errorCode ?? null
1153
1181
  };
1154
1182
  }
1155
- /** Refuses to realize more than the ledger's spendable yield (principal). */
1156
- async assertSpendableYield(shortfallRawUsdc) {
1157
- const url2 = `${this.config.facilitatorBaseUrl}/v1/wallets/${this.signer.walletAddress}/budget`;
1158
- let response;
1159
- try {
1160
- response = await this.fetchImpl(url2, {
1161
- headers: await walletAuthHeaders({
1162
- signer: this.signer,
1163
- method: "GET",
1164
- url: url2
1165
- })
1166
- });
1167
- } catch (error) {
1168
- throw new RelayerRealizeError(
1169
- "budget_unavailable",
1170
- "could not read the spendable-yield budget",
1171
- error
1172
- );
1183
+ /**
1184
+ * Reads the yield budget. Syncs the relayer's ledger from chain first (so
1185
+ * yield accrued since the last sync shows up); the sync is best-effort and
1186
+ * on failure the last-synced view is returned.
1187
+ */
1188
+ async getBudget(options = {}) {
1189
+ if (options.refreshFromChain !== false) {
1190
+ try {
1191
+ await this.postJson(
1192
+ "sync",
1193
+ `/v1/wallets/${this.signer.walletAddress}/sync`,
1194
+ { source: "chain" }
1195
+ );
1196
+ } catch {
1197
+ }
1173
1198
  }
1199
+ const url2 = `${this.baseUrl}/v1/wallets/${this.signer.walletAddress}/budget`;
1200
+ const response = await this.fetchImpl(url2, {
1201
+ headers: await walletAuthHeaders({
1202
+ signer: this.signer,
1203
+ method: "GET",
1204
+ url: url2
1205
+ })
1206
+ });
1207
+ const text = await response.text();
1174
1208
  if (response.status !== 200) {
1175
- throw new RelayerRealizeError(
1176
- "budget_unavailable",
1177
- `budget endpoint returned ${response.status}`
1209
+ throw new VaultFlowClientError(
1210
+ "budget",
1211
+ `budget endpoint returned ${response.status}: ${text}`
1178
1212
  );
1179
1213
  }
1180
- const body2 = await response.json();
1181
- const spendable = BigInt(body2.budget?.spendableYieldRawUsdc ?? "0");
1182
- if (spendable < shortfallRawUsdc) {
1183
- throw new RelayerRealizeError(
1184
- "insufficient_yield",
1185
- `spendable yield ${spendable} cannot cover ${shortfallRawUsdc} raw USDC; the principal is never spent \u2014 wait for more yield`,
1186
- { spendableYieldRawUsdc: spendable.toString() }
1214
+ let parsed;
1215
+ try {
1216
+ parsed = JSON.parse(text);
1217
+ } catch {
1218
+ throw new VaultFlowClientError(
1219
+ "budget",
1220
+ "budget endpoint returned 200 with a non-JSON body",
1221
+ text
1187
1222
  );
1188
1223
  }
1224
+ const body2 = parsed;
1225
+ return {
1226
+ wallet: this.signer.walletAddress,
1227
+ principalBasisRawUsdc: body2.position?.principalBasisRawUsdc ?? "0",
1228
+ positionValueRawUsdc: body2.budget?.positionValueRawUsdc ?? "0",
1229
+ grossYieldRawUsdc: body2.budget?.grossYieldRawUsdc ?? "0",
1230
+ spendableYieldRawUsdc: body2.budget?.spendableYieldRawUsdc ?? "0"
1231
+ };
1189
1232
  }
1190
1233
  /**
1191
- * Polls GET /v1/withdrawals/:id (which reconciles a submitted intent against
1192
- * the chain) until it leaves the "submitted" state or the timeout elapses.
1234
+ * Polls the reconciling GET endpoint until the intent leaves "submitted"
1235
+ * (each read looks the tx up on-chain) or the timeout elapses.
1193
1236
  */
1194
- async pollUntilSettled(withdrawalId, { timeoutMs = 9e4, intervalMs = 2500 } = {}) {
1195
- const deadline = Date.now() + timeoutMs;
1196
- let latest = null;
1237
+ async pollUntilTerminal(path, last) {
1238
+ const deadline = Date.now() + this.pollTimeoutMs;
1239
+ let latest = last;
1197
1240
  while (Date.now() < deadline) {
1198
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
1199
- const url2 = `${this.config.facilitatorBaseUrl}/v1/withdrawals/${withdrawalId}`;
1241
+ await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
1242
+ const url2 = `${this.baseUrl}${path}`;
1200
1243
  const response = await this.fetchImpl(url2, {
1201
1244
  headers: await walletAuthHeaders({
1202
1245
  signer: this.signer,
@@ -1207,19 +1250,19 @@ var RelayerYieldRealizer = class {
1207
1250
  if (response.status !== 200) {
1208
1251
  continue;
1209
1252
  }
1210
- latest = await response.json();
1253
+ try {
1254
+ latest = await response.json();
1255
+ } catch {
1256
+ continue;
1257
+ }
1211
1258
  if (latest.status !== "submitted") {
1212
1259
  return latest;
1213
1260
  }
1214
1261
  }
1215
- return latest ?? {
1216
- status: "submitted",
1217
- txSignature: null,
1218
- actualWithdrawRawUsdc: null
1219
- };
1262
+ return latest;
1220
1263
  }
1221
- async postJson(path, body2) {
1222
- const url2 = `${this.config.facilitatorBaseUrl}${path}`;
1264
+ async postJson(step, path, body2) {
1265
+ const url2 = `${this.baseUrl}${path}`;
1223
1266
  const serialized = JSON.stringify(body2);
1224
1267
  const response = await this.fetchImpl(url2, {
1225
1268
  method: "POST",
@@ -1236,22 +1279,133 @@ var RelayerYieldRealizer = class {
1236
1279
  });
1237
1280
  const text = await response.text();
1238
1281
  if (response.status !== 200) {
1282
+ throw new VaultFlowClientError(
1283
+ step,
1284
+ `${path} failed with ${response.status}: ${text}`,
1285
+ text
1286
+ );
1287
+ }
1288
+ try {
1289
+ return JSON.parse(text);
1290
+ } catch {
1291
+ throw new VaultFlowClientError(
1292
+ step,
1293
+ `${path} returned 200 with a non-JSON body`,
1294
+ text
1295
+ );
1296
+ }
1297
+ }
1298
+ };
1299
+
1300
+ // ../../src/client/relayer-yield-realizer.ts
1301
+ var REALIZE_OVERHEAD_RAW_USDC = 2500n;
1302
+ var RelayerRealizeError = class extends Error {
1303
+ constructor(code, message, detail = null) {
1304
+ super(message);
1305
+ this.code = code;
1306
+ this.detail = detail;
1307
+ this.name = "RelayerRealizeError";
1308
+ }
1309
+ code;
1310
+ detail;
1311
+ };
1312
+ var RelayerYieldRealizer = class {
1313
+ vaultFlows;
1314
+ constructor(config) {
1315
+ this.vaultFlows = new VaultFlowClient({
1316
+ facilitatorBaseUrl: config.facilitatorBaseUrl,
1317
+ signer: config.signer,
1318
+ rpc: config.rpc,
1319
+ ...config.fetchImpl === void 0 ? {} : { fetchImpl: config.fetchImpl },
1320
+ ...config.lookupTablesFor === void 0 ? {} : { lookupTablesFor: config.lookupTablesFor }
1321
+ });
1322
+ }
1323
+ async ensureUsdcAvailable(input) {
1324
+ const shortfallRawUsdc = input.amountRawUsdc;
1325
+ await this.assertSpendableYield(shortfallRawUsdc);
1326
+ let outcome;
1327
+ try {
1328
+ outcome = await this.vaultFlows.withdraw({
1329
+ amountRawUsdc: shortfallRawUsdc,
1330
+ // The relayer refuses to prepare this withdrawal beyond the spendable
1331
+ // yield — the principal-protection guard the client cannot bypass.
1332
+ purpose: "yield_realize"
1333
+ });
1334
+ } catch (error) {
1335
+ throw this.mapWithdrawError(error);
1336
+ }
1337
+ if (outcome.status !== "confirmed" || outcome.txSignature === null) {
1239
1338
  throw new RelayerRealizeError(
1240
- path.endsWith("/submit") ? "submit_failed" : "prepare_failed",
1241
- `${path} failed with ${response.status}: ${text}`
1339
+ "realize_not_confirmed",
1340
+ `yield realize withdrawal did not confirm (status=${outcome.status})`,
1341
+ outcome
1242
1342
  );
1243
1343
  }
1244
- return JSON.parse(text);
1344
+ return {
1345
+ realizedRawUsdc: BigInt(outcome.actualWithdrawRawUsdc ?? "0"),
1346
+ txSignature: outcome.txSignature
1347
+ };
1245
1348
  }
1246
- async readTokenBalance(ata) {
1349
+ /**
1350
+ * Refuses to realize more than the ledger's spendable yield (principal).
1351
+ * getBudget syncs the relayer's ledger from chain first (best-effort), so a
1352
+ * long-running client sees yield as it accrues instead of a frozen view.
1353
+ */
1354
+ async assertSpendableYield(shortfallRawUsdc) {
1355
+ let spendable;
1247
1356
  try {
1248
- const response = await this.rpc.getTokenAccountBalance(address2(ata), { commitment: "confirmed" }).send();
1249
- return BigInt(response.value.amount);
1250
- } catch {
1251
- return 0n;
1357
+ const budget = await this.vaultFlows.getBudget();
1358
+ spendable = BigInt(budget.spendableYieldRawUsdc);
1359
+ } catch (error) {
1360
+ throw new RelayerRealizeError(
1361
+ "budget_unavailable",
1362
+ "could not read the spendable-yield budget",
1363
+ error
1364
+ );
1365
+ }
1366
+ const requiredRawUsdc = shortfallRawUsdc + REALIZE_OVERHEAD_RAW_USDC;
1367
+ if (spendable < requiredRawUsdc) {
1368
+ throw new RelayerRealizeError(
1369
+ "insufficient_yield",
1370
+ `spendable yield ${spendable} cannot cover ${shortfallRawUsdc} raw USDC plus the ${REALIZE_OVERHEAD_RAW_USDC} raw fee headroom; the principal is never spent \u2014 wait for more yield`,
1371
+ { spendableYieldRawUsdc: spendable.toString() }
1372
+ );
1373
+ }
1374
+ }
1375
+ mapWithdrawError(error) {
1376
+ if (!(error instanceof VaultFlowClientError)) {
1377
+ return new RelayerRealizeError(
1378
+ "prepare_failed",
1379
+ `yield realize failed: ${error instanceof Error ? error.message : String(error)}`,
1380
+ error
1381
+ );
1382
+ }
1383
+ const serverCode = errorCodeFrom(error.detail);
1384
+ if (serverCode === "insufficient_yield" || serverCode === "post_state_principal_invariant_failed") {
1385
+ return new RelayerRealizeError(
1386
+ "insufficient_yield",
1387
+ "the relayer refused to realize beyond the spendable yield; the principal is never spent \u2014 wait for more yield",
1388
+ error.detail
1389
+ );
1252
1390
  }
1391
+ return new RelayerRealizeError(
1392
+ error.step === "submit" ? "submit_failed" : "prepare_failed",
1393
+ error.message,
1394
+ error.detail
1395
+ );
1253
1396
  }
1254
1397
  };
1398
+ function errorCodeFrom(detail) {
1399
+ if (typeof detail !== "string") {
1400
+ return null;
1401
+ }
1402
+ try {
1403
+ const parsed = JSON.parse(detail);
1404
+ return typeof parsed.error?.code === "string" ? parsed.error.code : null;
1405
+ } catch {
1406
+ return null;
1407
+ }
1408
+ }
1255
1409
 
1256
1410
  // ../../src/x402/headers.ts
1257
1411
  import { z } from "zod";
@@ -1319,6 +1473,14 @@ function decodeX402Header(headerValue) {
1319
1473
  );
1320
1474
  }
1321
1475
  }
1476
+ function requestBodyHashFor(body2) {
1477
+ if (body2 === null || body2 === void 0 || body2.length === 0) {
1478
+ return EMPTY_BODY_HASH;
1479
+ }
1480
+ return sha256TaggedHex(
1481
+ typeof body2 === "string" ? Buffer.from(body2, "utf8") : Buffer.from(body2)
1482
+ );
1483
+ }
1322
1484
 
1323
1485
  // ../../src/x402/standard-requirements.ts
1324
1486
  import { z as z2 } from "zod";
@@ -1401,6 +1563,24 @@ function selectPayableSolanaRequirement(requirements, options) {
1401
1563
  feePayer: requirement.extra?.feePayer ?? null
1402
1564
  };
1403
1565
  }
1566
+ function standardRequirementMatchesSelected(candidate, selected) {
1567
+ const parsed = standardExactRequirementSchema.safeParse(candidate);
1568
+ return parsed.success && stableJson(parsed.data) === stableJson(selected.requirement);
1569
+ }
1570
+ function stableJson(value) {
1571
+ return JSON.stringify(sortJson(value));
1572
+ }
1573
+ function sortJson(value) {
1574
+ if (Array.isArray(value)) {
1575
+ return value.map(sortJson);
1576
+ }
1577
+ if (value !== null && typeof value === "object") {
1578
+ return Object.fromEntries(
1579
+ Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, sortJson(entry)])
1580
+ );
1581
+ }
1582
+ return value;
1583
+ }
1404
1584
 
1405
1585
  // ../../src/client/standard-x402-payer.ts
1406
1586
  var StandardX402PayError = class extends Error {
@@ -1420,6 +1600,10 @@ var StandardX402Payer = class {
1420
1600
  defaultMaxAmountRawUsdc;
1421
1601
  network;
1422
1602
  usdcMint;
1603
+ stateStore;
1604
+ pending = /* @__PURE__ */ new Map();
1605
+ inFlight = /* @__PURE__ */ new Map();
1606
+ nowMs;
1423
1607
  constructor(config) {
1424
1608
  this.realizer = config.realizer;
1425
1609
  this.x402Fetch = config.x402Fetch;
@@ -1427,10 +1611,59 @@ var StandardX402Payer = class {
1427
1611
  this.defaultMaxAmountRawUsdc = config.defaultMaxAmountRawUsdc;
1428
1612
  this.network = config.network ?? SOLANA_MAINNET_NETWORK;
1429
1613
  this.usdcMint = config.usdcMint ?? SUBLY_VAULT.usdcMint;
1614
+ this.stateStore = config.stateStore ?? null;
1615
+ this.nowMs = config.nowMs ?? (() => Date.now());
1616
+ if (this.stateStore !== null) {
1617
+ for (const record of this.stateStore.load()) {
1618
+ this.pending.set(record.key, record);
1619
+ }
1620
+ }
1430
1621
  }
1431
- async pay(input) {
1622
+ pay(input) {
1623
+ const method2 = (input.method ?? "GET").toUpperCase();
1624
+ const requestBodyHash = requestBodyHashFor(input.body ?? null);
1625
+ const pendingKey = pendingPaymentKey({
1626
+ url: input.url,
1627
+ method: method2,
1628
+ requestBodyHash
1629
+ });
1630
+ const existingFlow = this.inFlight.get(pendingKey);
1631
+ if (existingFlow !== void 0) {
1632
+ return existingFlow;
1633
+ }
1634
+ const flow = this.run(input, {
1635
+ method: method2,
1636
+ requestBodyHash,
1637
+ pendingKey
1638
+ }).finally(() => {
1639
+ this.inFlight.delete(pendingKey);
1640
+ });
1641
+ this.inFlight.set(pendingKey, flow);
1642
+ return flow;
1643
+ }
1644
+ async run(input, computed) {
1645
+ const { method: method2, requestBodyHash, pendingKey } = computed;
1646
+ const existingPending = this.pending.get(pendingKey);
1647
+ if (existingPending !== void 0 && input.forceNewPayment !== true) {
1648
+ throw new StandardX402PayError(
1649
+ "payment_outcome_unknown",
1650
+ "a previous external x402 payment for this request has an unknown outcome. Verify whether it settled before purchasing again; to pay again anyway, call with forceNewPayment=true.",
1651
+ existingPending
1652
+ );
1653
+ }
1654
+ if (existingPending !== void 0 && input.forceNewPayment === true) {
1655
+ try {
1656
+ this.untrack(pendingKey);
1657
+ } catch (error) {
1658
+ throw new StandardX402PayError(
1659
+ "state_persist_failed",
1660
+ "could not clear the previous pending x402 marker before forcing a new payment",
1661
+ error
1662
+ );
1663
+ }
1664
+ }
1432
1665
  const init = {
1433
- method: input.method ?? "GET",
1666
+ method: method2,
1434
1667
  ...input.body === void 0 ? {} : { body: input.body },
1435
1668
  ...input.headers === void 0 ? {} : { headers: input.headers }
1436
1669
  };
@@ -1459,15 +1692,55 @@ var StandardX402Payer = class {
1459
1692
  error
1460
1693
  );
1461
1694
  }
1462
- const response = await this.x402Fetch(input.url, init);
1695
+ const pendingRecord = {
1696
+ key: pendingKey,
1697
+ url: input.url,
1698
+ method: method2,
1699
+ requestBodyHash,
1700
+ amountRawUsdc: selected.amountRawUsdc.toString(),
1701
+ payTo: selected.payTo,
1702
+ feePayer: selected.feePayer,
1703
+ realizedRawUsdc: realized.realizedRawUsdc.toString(),
1704
+ realizeTxSignature: realized.txSignature,
1705
+ status: "realized",
1706
+ createdAtMs: this.nowMs(),
1707
+ updatedAtMs: this.nowMs()
1708
+ };
1709
+ try {
1710
+ this.track(pendingRecord);
1711
+ } catch (error) {
1712
+ throw new StandardX402PayError(
1713
+ "state_persist_failed",
1714
+ "could not persist the pending x402 marker; refusing to attempt the external payment because a restart would not be double-payment safe",
1715
+ { error, pendingPayment: pendingRecord }
1716
+ );
1717
+ }
1718
+ let response;
1719
+ try {
1720
+ response = await this.x402Fetch(input.url, init, selected);
1721
+ } catch (error) {
1722
+ const persistError = this.tryMarkUnknown(pendingKey, {
1723
+ message: error instanceof Error ? error.message : String(error)
1724
+ });
1725
+ throw new StandardX402PayError(
1726
+ "payment_outcome_unknown",
1727
+ `the x402 payment attempt failed after yield was realized; verify whether it settled before paying again: ${error instanceof Error ? error.message : String(error)}`,
1728
+ { error, persistError }
1729
+ );
1730
+ }
1463
1731
  const bodyText = await response.text();
1464
1732
  if (response.status !== 200) {
1733
+ const persistError = this.tryMarkUnknown(pendingKey, {
1734
+ status: response.status,
1735
+ body: bodyText
1736
+ });
1465
1737
  throw new StandardX402PayError(
1466
- "delivery_failed",
1467
- `the x402 payment did not deliver (status ${response.status})`,
1468
- { status: response.status, body: bodyText }
1738
+ "payment_outcome_unknown",
1739
+ `the x402 payment attempt returned ${response.status} after yield was realized; verify whether it settled before paying again`,
1740
+ { status: response.status, body: bodyText, persistError }
1469
1741
  );
1470
1742
  }
1743
+ this.clearDelivered(pendingKey);
1471
1744
  return {
1472
1745
  paid: true,
1473
1746
  status: response.status,
@@ -1513,25 +1786,153 @@ var StandardX402Payer = class {
1513
1786
  );
1514
1787
  }
1515
1788
  }
1789
+ track(record) {
1790
+ const previous = this.pending.get(record.key);
1791
+ this.pending.set(record.key, record);
1792
+ try {
1793
+ this.persist();
1794
+ } catch (error) {
1795
+ if (previous === void 0) {
1796
+ this.pending.delete(record.key);
1797
+ } else {
1798
+ this.pending.set(record.key, previous);
1799
+ }
1800
+ throw error;
1801
+ }
1802
+ }
1803
+ markUnknown(key, detail) {
1804
+ const current = this.pending.get(key);
1805
+ if (current === void 0) {
1806
+ return;
1807
+ }
1808
+ const next = {
1809
+ ...current,
1810
+ status: "external_outcome_unknown",
1811
+ updatedAtMs: this.nowMs(),
1812
+ detail
1813
+ };
1814
+ this.pending.set(key, next);
1815
+ try {
1816
+ this.persist();
1817
+ } catch (error) {
1818
+ this.pending.set(key, current);
1819
+ throw error;
1820
+ }
1821
+ }
1822
+ tryMarkUnknown(key, detail) {
1823
+ try {
1824
+ this.markUnknown(key, detail);
1825
+ return null;
1826
+ } catch (error) {
1827
+ return error;
1828
+ }
1829
+ }
1830
+ untrack(key) {
1831
+ const previous = this.pending.get(key);
1832
+ const existed = previous !== void 0;
1833
+ this.pending.delete(key);
1834
+ try {
1835
+ this.persist();
1836
+ } catch (error) {
1837
+ if (existed) {
1838
+ this.pending.set(key, previous);
1839
+ }
1840
+ throw error;
1841
+ }
1842
+ }
1843
+ clearDelivered(key) {
1844
+ const previous = this.pending.get(key);
1845
+ this.pending.delete(key);
1846
+ try {
1847
+ this.persist();
1848
+ } catch (error) {
1849
+ if (previous !== void 0) {
1850
+ this.pending.set(key, previous);
1851
+ }
1852
+ console.error(
1853
+ `[subly-x402] payment delivered but pending marker could not be cleared: ${error instanceof Error ? error.message : String(error)}`
1854
+ );
1855
+ }
1856
+ }
1857
+ persist() {
1858
+ if (this.stateStore === null) {
1859
+ return;
1860
+ }
1861
+ this.stateStore.save([...this.pending.values()]);
1862
+ }
1516
1863
  };
1864
+ function pendingPaymentKey(input) {
1865
+ return `${input.method}:${input.url}:${input.requestBodyHash}`;
1866
+ }
1517
1867
 
1518
1868
  // ../../src/client/relayer-payer.ts
1519
1869
  function createRelayerX402Payer(config) {
1520
1870
  const realizer = new RelayerYieldRealizer({
1521
1871
  facilitatorBaseUrl: config.facilitatorBaseUrl,
1522
1872
  signer: config.signer,
1523
- rpc: config.rpc,
1524
- forceRealizeFullAmount: config.forceRealizeFullAmount ?? false
1873
+ rpc: config.rpc
1525
1874
  });
1526
1875
  return new StandardX402Payer({
1527
1876
  realizer,
1528
1877
  x402Fetch: config.x402Fetch,
1529
- defaultMaxAmountRawUsdc: config.defaultMaxAmountRawUsdc
1878
+ defaultMaxAmountRawUsdc: config.defaultMaxAmountRawUsdc,
1879
+ ...config.stateStore === void 0 ? {} : { stateStore: config.stateStore }
1530
1880
  });
1531
1881
  }
1532
1882
 
1883
+ // ../../src/client/standard-x402-state-store.ts
1884
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1885
+ import { basename, dirname, join } from "node:path";
1886
+ function fileStandardX402StateStore(path) {
1887
+ return {
1888
+ load() {
1889
+ let text;
1890
+ try {
1891
+ text = readFileSync(path, "utf8");
1892
+ } catch (error) {
1893
+ if (isMissingFileError(error)) {
1894
+ return [];
1895
+ }
1896
+ throw error;
1897
+ }
1898
+ const parsed = JSON.parse(text);
1899
+ if (!Array.isArray(parsed)) {
1900
+ throw new Error(`pending payment state is not an array: ${path}`);
1901
+ }
1902
+ for (const [index, record] of parsed.entries()) {
1903
+ if (!isPendingPaymentRecord(record)) {
1904
+ throw new Error(
1905
+ `pending payment state has an invalid record at index ${index}: ${path}`
1906
+ );
1907
+ }
1908
+ }
1909
+ return parsed;
1910
+ },
1911
+ save(records) {
1912
+ const directory = dirname(path);
1913
+ mkdirSync(directory, { recursive: true });
1914
+ const tempPath = join(
1915
+ directory,
1916
+ `.${basename(path)}.${process.pid}.${Date.now()}.tmp`
1917
+ );
1918
+ writeFileSync(tempPath, JSON.stringify(records, null, 2));
1919
+ renameSync(tempPath, path);
1920
+ }
1921
+ };
1922
+ }
1923
+ function isMissingFileError(error) {
1924
+ return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
1925
+ }
1926
+ function isPendingPaymentRecord(value) {
1927
+ if (value === null || typeof value !== "object") {
1928
+ return false;
1929
+ }
1930
+ const record = value;
1931
+ return typeof record.key === "string" && typeof record.url === "string" && typeof record.method === "string" && typeof record.requestBodyHash === "string" && typeof record.amountRawUsdc === "string" && typeof record.payTo === "string" && (record.feePayer === null || typeof record.feePayer === "string") && typeof record.realizedRawUsdc === "string" && (record.realizeTxSignature === null || typeof record.realizeTxSignature === "string") && (record.status === "realized" || record.status === "external_outcome_unknown") && typeof record.createdAtMs === "number" && typeof record.updatedAtMs === "number";
1932
+ }
1933
+
1533
1934
  // ../../src/solana/keys.ts
1534
- import { readFileSync } from "node:fs";
1935
+ import { readFileSync as readFileSync2 } from "node:fs";
1535
1936
  import bs586 from "bs58";
1536
1937
  import {
1537
1938
  createKeyPairSignerFromBytes
@@ -1546,7 +1947,7 @@ async function loadKeyPairSigner(params) {
1546
1947
  return createKeyPairSignerFromBytes(bytes);
1547
1948
  }
1548
1949
  if (jsonFilePath !== void 0 && jsonFilePath.length > 0) {
1549
- const raw = JSON.parse(readFileSync(jsonFilePath, "utf8"));
1950
+ const raw = JSON.parse(readFileSync2(jsonFilePath, "utf8"));
1550
1951
  if (!Array.isArray(raw) || raw.length !== 64) {
1551
1952
  throw new Error(`${label} keypair file must be a 64-byte JSON array`);
1552
1953
  }
@@ -1564,7 +1965,7 @@ function loadSecretKeyBytes(params) {
1564
1965
  return bytes;
1565
1966
  }
1566
1967
  if (jsonFilePath !== void 0 && jsonFilePath.length > 0) {
1567
- const raw = JSON.parse(readFileSync(jsonFilePath, "utf8"));
1968
+ const raw = JSON.parse(readFileSync2(jsonFilePath, "utf8"));
1568
1969
  if (!Array.isArray(raw) || raw.length !== 64) {
1569
1970
  throw new Error(`${label} keypair file must be a 64-byte JSON array`);
1570
1971
  }
@@ -1587,15 +1988,29 @@ async function createSvmX402Fetch(params) {
1587
1988
  const signer2 = toClientSvmSigner(
1588
1989
  await createKeyPairSignerFromBytes2(params.agentSecretKey)
1589
1990
  );
1590
- const wrapped = wrapFetchWithPaymentFromConfig(fetch, {
1591
- schemes: [
1592
- {
1593
- network: "solana:*",
1594
- client: new ExactSvmScheme(signer2, { rpcUrl: params.rpcUrl })
1595
- }
1596
- ]
1597
- });
1598
- return (url2, init) => wrapped(url2, init);
1991
+ return (url2, init, expected) => {
1992
+ const wrapped = wrapFetchWithPaymentFromConfig(fetch, {
1993
+ schemes: [
1994
+ {
1995
+ network: expected.requirement.network,
1996
+ client: new ExactSvmScheme(signer2, { rpcUrl: params.rpcUrl })
1997
+ }
1998
+ ],
1999
+ paymentRequirementsSelector: (_x402Version, requirements) => selectExpectedRequirement(requirements, expected)
2000
+ });
2001
+ return wrapped(url2, init);
2002
+ };
2003
+ }
2004
+ function selectExpectedRequirement(requirements, expected) {
2005
+ const match = requirements.find(
2006
+ (candidate) => standardRequirementMatchesSelected(candidate, expected)
2007
+ );
2008
+ if (match === void 0) {
2009
+ throw new Error(
2010
+ "x402 challenge changed after preflight; refusing to pay an unchecked requirement"
2011
+ );
2012
+ }
2013
+ return match;
1599
2014
  }
1600
2015
 
1601
2016
  // src/pay.ts
@@ -1609,9 +2024,10 @@ if (url === void 0 || !/^https?:\/\//.test(url)) {
1609
2024
  fail("Usage: pay fetch <url> [maxAmountRawUsdc]");
1610
2025
  }
1611
2026
  var maxAmountArg = process.argv[3];
1612
- var facilitatorBaseUrl = process.env.SUBLY_FACILITATOR_URL ?? "https://api.demo.sublyfi.com";
2027
+ var relayerBaseUrl = process.env.SUBLY_RELAYER_URL ?? process.env.SUBLY_FACILITATOR_URL ?? "https://api.demo.sublyfi.com";
1613
2028
  var rpcUrl = process.env.SOLANA_RPC_URL ?? "https://api.mainnet-beta.solana.com";
1614
2029
  var defaultMaxAmountRawUsdc = process.env.SUBLY_MCP_MAX_AMOUNT_RAW_USDC === void 0 ? 10000n : BigInt(process.env.SUBLY_MCP_MAX_AMOUNT_RAW_USDC);
2030
+ var pendingStatePath = process.env.SUBLY_MCP_STATE_PATH ?? join2(homedir(), ".subly", "standard-x402-pending.json");
1615
2031
  var keyPairSigner = await loadKeyPairSigner({
1616
2032
  base58Secret: process.env.SUBLY_DEMO_AGENT_KEYPAIR,
1617
2033
  jsonFilePath: process.env.SUBLY_DEMO_AGENT_KEYPAIR_PATH,
@@ -1625,17 +2041,18 @@ var agentSecretKey = loadSecretKeyBytes({
1625
2041
  var signer = new LocalKeypairAgentWalletSigner(keyPairSigner);
1626
2042
  var rpc = createRpc(rpcUrl);
1627
2043
  var payer = createRelayerX402Payer({
1628
- facilitatorBaseUrl,
2044
+ facilitatorBaseUrl: relayerBaseUrl,
1629
2045
  signer,
1630
2046
  rpc,
1631
2047
  x402Fetch: await createSvmX402Fetch({ agentSecretKey, rpcUrl }),
1632
- defaultMaxAmountRawUsdc
2048
+ defaultMaxAmountRawUsdc,
2049
+ stateStore: fileStandardX402StateStore(pendingStatePath)
1633
2050
  });
1634
2051
  var method = process.env.SUBLY_PAY_METHOD;
1635
2052
  var body = process.env.SUBLY_PAY_BODY;
1636
2053
  console.error(`[pay] agent ${signer.walletAddress} -> ${url}`);
1637
2054
  try {
1638
- await ensureWalletOnboarded({ facilitatorBaseUrl, signer });
2055
+ await ensureWalletOnboarded({ facilitatorBaseUrl: relayerBaseUrl, signer });
1639
2056
  } catch (error) {
1640
2057
  console.error(
1641
2058
  `[pay] onboarding skipped: ${error instanceof Error ? error.message : String(error)}`
@@ -1646,7 +2063,8 @@ try {
1646
2063
  url,
1647
2064
  ...method === void 0 ? {} : { method },
1648
2065
  ...body === void 0 ? {} : { body, headers: { "content-type": "application/json" } },
1649
- ...maxAmountArg === void 0 ? {} : { maxAmountRawUsdc: BigInt(maxAmountArg) }
2066
+ ...maxAmountArg === void 0 ? {} : { maxAmountRawUsdc: BigInt(maxAmountArg) },
2067
+ ...process.env.SUBLY_PAY_FORCE_NEW_PAYMENT === "1" ? { forceNewPayment: true } : {}
1650
2068
  });
1651
2069
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1652
2070
  `);