@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/README.md CHANGED
@@ -7,7 +7,10 @@ locally with your own Solana key; Subly never holds it.
7
7
 
8
8
  Ships one `pay` dispatcher bin with subcommands, all runnable with `npx` (no clone):
9
9
 
10
- - `pay mcp` — an MCP server (Claude Code, Cursor, any MCP client)
10
+ - `pay mcp` — an MCP server (Claude Code, Cursor, any MCP client) exposing
11
+ the full lifecycle as tools: `deposit_to_subly_vault`,
12
+ `get_subly_yield_budget`, `fetch_with_subly_payment`,
13
+ `withdraw_from_subly_vault`
11
14
  - `pay fetch <url>` — one-shot: pay for a URL, print the receipt (used by the
12
15
  OpenClaw skill)
13
16
  - `pay deposit <amountRawUsdc>` / `pay withdraw <amountRawUsdc>` — vault
@@ -45,10 +48,14 @@ npx -y @subly_fi/pay fetch https://seller.example.com/api/premium
45
48
  | Var | Required | Default |
46
49
  |---|---|---|
47
50
  | `SUBLY_DEMO_AGENT_KEYPAIR_PATH` | yes (or `SUBLY_DEMO_AGENT_KEYPAIR` base58) | — |
48
- | `SUBLY_FACILITATOR_URL` | no | `https://api.demo.sublyfi.com` |
51
+ | `SUBLY_RELAYER_URL` | no | `https://api.demo.sublyfi.com` |
49
52
  | `SOLANA_RPC_URL` | no | public mainnet RPC |
50
53
  | `SUBLY_MCP_MAX_AMOUNT_RAW_USDC` | no | `10000` (0.01 USDC) per-payment cap |
51
54
 
52
55
  Requests authenticate with a signature from your wallet key — there is no API
53
- token. The cap and the facilitator's yield-budget check both bound spending;
54
- the principal is never touched.
56
+ token. `SUBLY_FACILITATOR_URL` is still accepted as a legacy fallback for
57
+ `SUBLY_RELAYER_URL`. Spending is bounded twice: the client cap, and the
58
+ relayer's server-side guard that refuses to realize anything beyond the
59
+ spendable yield — the deposited principal is never touched by a payment.
60
+ (A plain `pay withdraw` is the exit path and may of course move principal
61
+ back to your wallet.)
package/dist/deposit.js CHANGED
@@ -48,10 +48,10 @@ function decodeSerializedTransaction(serializedBase64) {
48
48
  }
49
49
  async function addSignaturesToSerializedTransaction(params) {
50
50
  const decoded = decodeSerializedTransaction(params.serializedBase64);
51
- const signed2 = await partiallySignTransaction(params.signers, decoded);
51
+ const signed = await partiallySignTransaction(params.signers, decoded);
52
52
  return {
53
- serializedBase64: getBase64EncodedWireTransaction(signed2),
54
- transaction: signed2
53
+ serializedBase64: getBase64EncodedWireTransaction(signed),
54
+ transaction: signed
55
55
  };
56
56
  }
57
57
  function signatureBase58ForSigner(transaction, signer2) {
@@ -1076,6 +1076,222 @@ async function fetchLookupTablesForTransaction(rpc2, serializedTransaction) {
1076
1076
  return result;
1077
1077
  }
1078
1078
 
1079
+ // ../../src/client/vault-flows.ts
1080
+ var VaultFlowClientError = class extends Error {
1081
+ constructor(step, message, detail = null) {
1082
+ super(message);
1083
+ this.step = step;
1084
+ this.detail = detail;
1085
+ this.name = "VaultFlowClientError";
1086
+ }
1087
+ step;
1088
+ detail;
1089
+ };
1090
+ var VaultFlowClient = class {
1091
+ baseUrl;
1092
+ signer;
1093
+ fetchImpl;
1094
+ lookupTablesFor;
1095
+ pollTimeoutMs;
1096
+ pollIntervalMs;
1097
+ constructor(config) {
1098
+ this.baseUrl = config.facilitatorBaseUrl.replace(/\/$/, "");
1099
+ this.signer = config.signer;
1100
+ this.fetchImpl = config.fetchImpl ?? fetch;
1101
+ this.lookupTablesFor = config.lookupTablesFor ?? ((serializedTransaction) => fetchLookupTablesForTransaction(config.rpc, serializedTransaction));
1102
+ this.pollTimeoutMs = config.pollTimeoutMs ?? 9e4;
1103
+ this.pollIntervalMs = config.pollIntervalMs ?? 2500;
1104
+ }
1105
+ /** Moves USDC from the agent wallet into the vault (fee sponsored). */
1106
+ async deposit(input) {
1107
+ const prepared = await this.postJson("prepare", "/v1/deposits/prepare", {
1108
+ wallet: this.signer.walletAddress,
1109
+ amountRawUsdc: input.amountRawUsdc.toString()
1110
+ });
1111
+ const signed = await this.signer.signDeposit({
1112
+ intent: prepared.signingIntent,
1113
+ serializedTransaction: prepared.serializedTransaction,
1114
+ lookupTables: await this.lookupTablesFor(prepared.serializedTransaction)
1115
+ });
1116
+ let outcome = await this.postJson("submit", "/v1/deposits/submit", {
1117
+ depositId: prepared.depositId,
1118
+ serializedTransaction: signed.serializedTransaction,
1119
+ agentSignature: signed.agentSignature
1120
+ });
1121
+ if (outcome.status === "submitted") {
1122
+ outcome = await this.pollUntilTerminal(
1123
+ `/v1/deposits/${prepared.depositId}`,
1124
+ outcome
1125
+ );
1126
+ }
1127
+ return {
1128
+ depositId: prepared.depositId,
1129
+ status: outcome.status,
1130
+ txSignature: outcome.txSignature ?? null,
1131
+ actualDepositRawUsdc: outcome.actualDepositRawUsdc ?? null,
1132
+ sharesMintedRaw: outcome.sharesMintedRaw ?? null,
1133
+ errorCode: outcome.errorCode ?? null
1134
+ };
1135
+ }
1136
+ /**
1137
+ * Moves USDC from the vault back to the agent wallet's USDC ATA (fee
1138
+ * sponsored). A plain withdrawal is the exit path and MAY spend principal;
1139
+ * with purpose "yield_realize" the relayer refuses anything beyond the
1140
+ * spendable yield (the payment path, via RelayerYieldRealizer).
1141
+ */
1142
+ async withdraw(input) {
1143
+ const prepared = await this.postJson(
1144
+ "prepare",
1145
+ "/v1/withdrawals/prepare",
1146
+ {
1147
+ wallet: this.signer.walletAddress,
1148
+ amountRawUsdc: input.amountRawUsdc.toString(),
1149
+ ...input.purpose === void 0 ? {} : { purpose: input.purpose }
1150
+ }
1151
+ );
1152
+ const signed = await this.signer.signWithdrawal({
1153
+ intent: prepared.signingIntent,
1154
+ serializedTransaction: prepared.serializedTransaction,
1155
+ lookupTables: await this.lookupTablesFor(prepared.serializedTransaction)
1156
+ });
1157
+ let outcome = await this.postJson("submit", "/v1/withdrawals/submit", {
1158
+ withdrawalId: prepared.withdrawalId,
1159
+ serializedTransaction: signed.serializedTransaction,
1160
+ agentSignature: signed.agentSignature
1161
+ });
1162
+ if (outcome.status === "submitted") {
1163
+ outcome = await this.pollUntilTerminal(
1164
+ `/v1/withdrawals/${prepared.withdrawalId}`,
1165
+ outcome
1166
+ );
1167
+ }
1168
+ return {
1169
+ withdrawalId: prepared.withdrawalId,
1170
+ status: outcome.status,
1171
+ txSignature: outcome.txSignature ?? null,
1172
+ destinationUsdcAta: prepared.destinationUsdcAta,
1173
+ actualWithdrawRawUsdc: outcome.actualWithdrawRawUsdc ?? null,
1174
+ actualSharesBurnedRaw: outcome.actualSharesBurnedRaw ?? null,
1175
+ errorCode: outcome.errorCode ?? null
1176
+ };
1177
+ }
1178
+ /**
1179
+ * Reads the yield budget. Syncs the relayer's ledger from chain first (so
1180
+ * yield accrued since the last sync shows up); the sync is best-effort and
1181
+ * on failure the last-synced view is returned.
1182
+ */
1183
+ async getBudget(options = {}) {
1184
+ if (options.refreshFromChain !== false) {
1185
+ try {
1186
+ await this.postJson(
1187
+ "sync",
1188
+ `/v1/wallets/${this.signer.walletAddress}/sync`,
1189
+ { source: "chain" }
1190
+ );
1191
+ } catch {
1192
+ }
1193
+ }
1194
+ const url = `${this.baseUrl}/v1/wallets/${this.signer.walletAddress}/budget`;
1195
+ const response = await this.fetchImpl(url, {
1196
+ headers: await walletAuthHeaders({
1197
+ signer: this.signer,
1198
+ method: "GET",
1199
+ url
1200
+ })
1201
+ });
1202
+ const text = await response.text();
1203
+ if (response.status !== 200) {
1204
+ throw new VaultFlowClientError(
1205
+ "budget",
1206
+ `budget endpoint returned ${response.status}: ${text}`
1207
+ );
1208
+ }
1209
+ let parsed;
1210
+ try {
1211
+ parsed = JSON.parse(text);
1212
+ } catch {
1213
+ throw new VaultFlowClientError(
1214
+ "budget",
1215
+ "budget endpoint returned 200 with a non-JSON body",
1216
+ text
1217
+ );
1218
+ }
1219
+ const body = parsed;
1220
+ return {
1221
+ wallet: this.signer.walletAddress,
1222
+ principalBasisRawUsdc: body.position?.principalBasisRawUsdc ?? "0",
1223
+ positionValueRawUsdc: body.budget?.positionValueRawUsdc ?? "0",
1224
+ grossYieldRawUsdc: body.budget?.grossYieldRawUsdc ?? "0",
1225
+ spendableYieldRawUsdc: body.budget?.spendableYieldRawUsdc ?? "0"
1226
+ };
1227
+ }
1228
+ /**
1229
+ * Polls the reconciling GET endpoint until the intent leaves "submitted"
1230
+ * (each read looks the tx up on-chain) or the timeout elapses.
1231
+ */
1232
+ async pollUntilTerminal(path, last) {
1233
+ const deadline = Date.now() + this.pollTimeoutMs;
1234
+ let latest = last;
1235
+ while (Date.now() < deadline) {
1236
+ await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
1237
+ const url = `${this.baseUrl}${path}`;
1238
+ const response = await this.fetchImpl(url, {
1239
+ headers: await walletAuthHeaders({
1240
+ signer: this.signer,
1241
+ method: "GET",
1242
+ url
1243
+ })
1244
+ });
1245
+ if (response.status !== 200) {
1246
+ continue;
1247
+ }
1248
+ try {
1249
+ latest = await response.json();
1250
+ } catch {
1251
+ continue;
1252
+ }
1253
+ if (latest.status !== "submitted") {
1254
+ return latest;
1255
+ }
1256
+ }
1257
+ return latest;
1258
+ }
1259
+ async postJson(step, path, body) {
1260
+ const url = `${this.baseUrl}${path}`;
1261
+ const serialized = JSON.stringify(body);
1262
+ const response = await this.fetchImpl(url, {
1263
+ method: "POST",
1264
+ headers: {
1265
+ ...await walletAuthHeaders({
1266
+ signer: this.signer,
1267
+ method: "POST",
1268
+ url,
1269
+ body: serialized
1270
+ }),
1271
+ "content-type": "application/json"
1272
+ },
1273
+ body: serialized
1274
+ });
1275
+ const text = await response.text();
1276
+ if (response.status !== 200) {
1277
+ throw new VaultFlowClientError(
1278
+ step,
1279
+ `${path} failed with ${response.status}: ${text}`,
1280
+ text
1281
+ );
1282
+ }
1283
+ try {
1284
+ return JSON.parse(text);
1285
+ } catch {
1286
+ throw new VaultFlowClientError(
1287
+ step,
1288
+ `${path} returned 200 with a non-JSON body`,
1289
+ text
1290
+ );
1291
+ }
1292
+ }
1293
+ };
1294
+
1079
1295
  // ../../src/solana/keys.ts
1080
1296
  import { readFileSync } from "node:fs";
1081
1297
  import bs586 from "bs58";
@@ -1120,7 +1336,7 @@ function formatRawUsdc(raw) {
1120
1336
  }
1121
1337
 
1122
1338
  // ../../demo/deposit.ts
1123
- var facilitatorBaseUrl = process.env.SUBLY_FACILITATOR_URL ?? "https://api.demo.sublyfi.com";
1339
+ var relayerBaseUrl = process.env.SUBLY_RELAYER_URL ?? process.env.SUBLY_FACILITATOR_URL ?? "https://api.demo.sublyfi.com";
1124
1340
  var amountRawUsdc = process.argv[2];
1125
1341
  if (amountRawUsdc === void 0 || !/^[1-9]\d*$/.test(amountRawUsdc)) {
1126
1342
  fail(
@@ -1136,69 +1352,44 @@ var signer = new LocalKeypairAgentWalletSigner(keyPairSigner);
1136
1352
  var rpc = createRpc(
1137
1353
  process.env.SOLANA_RPC_URL ?? "https://api.mainnet-beta.solana.com"
1138
1354
  );
1139
- async function postJson(path, body) {
1140
- const url = `${facilitatorBaseUrl}${path}`;
1141
- const serialized = JSON.stringify(body);
1142
- const response = await fetch(url, {
1143
- method: "POST",
1144
- headers: {
1145
- ...await walletAuthHeaders({
1146
- signer,
1147
- method: "POST",
1148
- url,
1149
- body: serialized
1150
- }),
1151
- "content-type": "application/json"
1152
- },
1153
- body: serialized
1154
- });
1155
- const text = await response.text();
1156
- if (response.status !== 200) {
1157
- fail(`[deposit] ${path} failed with ${response.status}: ${text}`);
1158
- }
1159
- return JSON.parse(text);
1160
- }
1355
+ var vaultFlows = new VaultFlowClient({
1356
+ facilitatorBaseUrl: relayerBaseUrl,
1357
+ signer,
1358
+ rpc
1359
+ });
1161
1360
  console.log(`[deposit] agent wallet: ${signer.walletAddress}`);
1162
- console.log(`[deposit] facilitator: ${facilitatorBaseUrl}`);
1361
+ console.log(`[deposit] relayer: ${relayerBaseUrl}`);
1163
1362
  console.log("\n[deposit] step 0: ensure the wallet is registered (self-serve)");
1164
- await ensureWalletOnboarded({ facilitatorBaseUrl, signer });
1363
+ await ensureWalletOnboarded({ facilitatorBaseUrl: relayerBaseUrl, signer });
1165
1364
  console.log("[deposit] wallet registered and synced");
1166
1365
  console.log(
1167
1366
  `
1168
- [deposit] step 1: prepare deposit of ${formatRawUsdc(amountRawUsdc)} USDC`
1367
+ [deposit] step 1: deposit ${formatRawUsdc(amountRawUsdc)} USDC (prepare -> validate intent + sign locally -> sponsor submits)`
1169
1368
  );
1170
- var prepared = await postJson("/v1/deposits/prepare", {
1171
- wallet: signer.walletAddress,
1172
- amountRawUsdc
1173
- });
1174
- console.log(`[deposit] prepared (depositId=${prepared.depositId})`);
1175
- console.log(
1176
- "\n[deposit] step 2: validate the signing intent against the transaction, sign locally"
1177
- );
1178
- var signed = await signer.signDeposit({
1179
- intent: prepared.signingIntent,
1180
- serializedTransaction: prepared.serializedTransaction,
1181
- lookupTables: await fetchLookupTablesForTransaction(
1182
- rpc,
1183
- prepared.serializedTransaction
1184
- )
1185
- });
1186
- console.log("[deposit] agent signature attached");
1187
- console.log("\n[deposit] step 3: submit (sponsor co-signs and broadcasts)");
1188
- var submitted = await postJson("/v1/deposits/submit", {
1189
- depositId: prepared.depositId,
1190
- serializedTransaction: signed.serializedTransaction,
1191
- agentSignature: signed.agentSignature
1192
- });
1369
+ var submitted;
1370
+ try {
1371
+ submitted = await vaultFlows.deposit({ amountRawUsdc: BigInt(amountRawUsdc) });
1372
+ } catch (error) {
1373
+ if (error instanceof VaultFlowClientError) {
1374
+ fail(`[deposit] ${error.step} failed: ${error.message}`);
1375
+ }
1376
+ throw error;
1377
+ }
1378
+ console.log(`[deposit] depositId: ${submitted.depositId}`);
1193
1379
  console.log(`[deposit] status: ${submitted.status}`);
1194
1380
  if (submitted.txSignature !== null) {
1195
1381
  console.log(
1196
1382
  `[deposit] transaction: https://solscan.io/tx/${submitted.txSignature}`
1197
1383
  );
1198
1384
  }
1385
+ if (submitted.status === "submitted") {
1386
+ fail(
1387
+ `[deposit] broadcast but not yet confirmed \u2014 it may still land. Do NOT resubmit; check GET /v1/deposits/${submitted.depositId} (or the tx link above) first`
1388
+ );
1389
+ }
1199
1390
  if (submitted.status !== "confirmed") {
1200
1391
  fail(
1201
- `[deposit] not confirmed (errorCode=${submitted.errorCode}); check GET /v1/deposits/${prepared.depositId} and the facilitator logs`
1392
+ `[deposit] not confirmed (errorCode=${submitted.errorCode}); check GET /v1/deposits/${submitted.depositId} and the relayer logs`
1202
1393
  );
1203
1394
  }
1204
1395
  console.log(