@subly_fi/pay 0.7.2 → 0.8.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
@@ -2,55 +2,59 @@
2
2
 
3
3
  CLI and stdio MCP client for paying compatible x402 APIs with Kamino USDC vault yield on Solana. MIT licensed. Works with your own [Subly relayer](https://github.com/SublyFi/subly-payment-protocol/tree/main/deploy); no Subly account is required.
4
4
 
5
- Version 0.7 is beta software and has not had an external security audit. Vault operations use real mainnet funds. Yield accounting and owner policies depend on your relayer operator. Read the [security model](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/security-model.md).
5
+ Version 0.8 is beta software and has not had an external security audit. Vault operations use real mainnet funds. Yield accounting and owner policies depend on your relayer operator. Read the [security model](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/security-model.md).
6
6
 
7
7
  ## Quick start
8
8
 
9
9
  You need **Node.js 24+**, a Solana agent wallet with mainnet USDC, a trusted relayer URL, and a mainnet RPC endpoint that supports transaction simulation with inner instructions. Your wallet can be a local keypair or a supported custody signer. The owner who approves spending controls can use a passkey or a separate Solana wallet.
10
10
 
11
11
  ```bash
12
- npx -y @subly_fi/pay@0.7.2 --help
12
+ npx -y @subly_fi/pay@0.8.0 --help
13
13
  export SUBLY_RELAYER_URL=https://your-relayer.example.com
14
14
  export SOLANA_RPC_URL=https://your-mainnet-rpc.example.com
15
15
  export SUBLY_DEMO_AGENT_KEYPAIR_PATH=/absolute/path/to/agent.json
16
- npx -y @subly_fi/pay@0.7.2 doctor
17
- npx -y @subly_fi/pay@0.7.2 vaults
16
+ npx -y @subly_fi/pay@0.8.0 doctor
17
+ npx -y @subly_fi/pay@0.8.0 vaults
18
18
  ```
19
19
 
20
20
  Use an existing dedicated agent wallet, or create one with `solana-keygen new -o agent.json`. Keep its recovery material private and restrict the file to its owner (`chmod 600 agent.json`). Fund its public address with **USDC on Solana mainnet**. Subly does not create or fund wallets. Vault fees require a funded relayer sponsor; the final API payment requires the seller's facilitator fee payer.
21
21
 
22
22
  1. Review the selected vault, its curator, fees and liquidity. For a catalogue supplied by your operator, install the reviewed file locally and set `SUBLY_VAULTS_FILE=/absolute/path/vaults.json`. `SUBLY_VAULT_ADDRESS` selects one listed vault for CLI commands. Never install transaction trust anchors merely because a remote response says to.
23
- 2. Create an owner setup link. This example pre-approves a **1.01 USDC** deposit; the selected vault's minimum can differ:
23
+ 2. Create an owner setup link. On the first owner registration, this example pre-approves a **1.01 USDC** deposit; the selected vault's minimum can differ:
24
24
 
25
25
  ```bash
26
- npx -y @subly_fi/pay@0.7.2 setup-link --initial-deposit 1010000
26
+ npx -y @subly_fi/pay@0.8.0 setup-link --initial-deposit 1010000
27
27
  ```
28
28
 
29
- Open the returned `setupUrl`, review the wallet, vault and limits, then approve with your passkey or wallet. Links expire in 10 minutes. Treat setup and approval links as private capabilities. The first person completing an initial setup becomes the owner for that wallet/vault.
29
+ Open the returned `setupUrl`, review the wallet, vault and limits, then approve with your passkey or wallet. Links expire in 10 minutes. Treat setup and approval links as private capabilities. The first person completing an initial setup becomes the owner for that wallet/vault. Replacing an existing mandate requires a separate deposit approval; follow the approval link returned by the deposit command.
30
30
  3. Check completion and deposit promptly; initial deposit approval lasts about 15 minutes:
31
31
 
32
32
  ```bash
33
- npx -y @subly_fi/pay@0.7.2 setup-status <sessionId>
34
- npx -y @subly_fi/pay@0.7.2 deposit 1010000
35
- npx -y @subly_fi/pay@0.7.2 budget
33
+ npx -y @subly_fi/pay@0.8.0 setup-status <sessionId>
34
+ npx -y @subly_fi/pay@0.8.0 deposit 1010000
35
+ npx -y @subly_fi/pay@0.8.0 budget
36
36
  ```
37
37
 
38
38
  4. Wait until **spendable yield** covers the price and vault fees. A new deposit does not immediately provide a payment budget. Then request a compatible API:
39
39
 
40
40
  ```bash
41
- npx -y @subly_fi/pay@0.7.2 fetch https://seller.example.com/paid-resource
41
+ npx -y @subly_fi/pay@0.8.0 fetch https://seller.example.com/paid-resource
42
42
  ```
43
43
 
44
44
  5. Withdraw funds back to the same agent wallet when needed:
45
45
 
46
46
  ```bash
47
- npx -y @subly_fi/pay@0.7.2 withdraw 1000000
47
+ npx -y @subly_fi/pay@0.8.0 withdraw 1000000
48
48
  ```
49
49
 
50
50
  All amounts are raw USDC integers: `1000000` = 1 USDC. `fetch` defaults to a **0.01 USDC cap**, configurable with `SUBLY_MCP_MAX_AMOUNT_RAW_USDC` or `fetch <URL> <capRawUSDC>`. The owner mandate may set stricter limits. `setup-link --help` lists policy options. A withdrawal can include principal and is subject to liquidity, fees and the owner's policy. A revoked mandate also blocks relayer withdrawals.
51
51
 
52
52
  A subsequent deposit/payment/withdrawal may return `approvalRequired` with an `approveUrl`. After the owner approves, retry the same operation with the returned `apr_...` as a trailing argument. Never automatically retry a transaction reported as submitted or an API payment with an unknown outcome.
53
53
 
54
+ Status lookup requires a **0.8.0 or newer relayer** so it can disable rebroadcast of an unresolved transaction with the authenticated `?resubmit=false` option. Upgrade the relayer before using this command.
55
+
56
+ For an interrupted deposit or withdrawal, keep its `depositId` (`dep_...`) or `withdrawalId` (`wdr_...`) and run `pay status <intentId>` with the same wallet, selected vault and relayer. This reads and reconciles the original operation; it does not prepare, sign or send another transaction. The result includes the requested/confirmed amount, transaction signature and next action. `submitted` means check the same ID again later. A successful status command exits zero even when the original operation is still pending or failed; inspect its `status` and `nextAction`. Wallet-auth message signing is required, but no client RPC call is needed.
57
+
54
58
  ## MCP configuration
55
59
 
56
60
  Add this to the MCP configuration of your editor or agent host. Replace all example values with your own absolute paths and endpoints. Pinning the version keeps upgrades explicit.
@@ -60,7 +64,7 @@ Add this to the MCP configuration of your editor or agent host. Replace all exam
60
64
  "mcpServers": {
61
65
  "subly": {
62
66
  "command": "npx",
63
- "args": ["-y", "@subly_fi/pay@0.7.2", "mcp"],
67
+ "args": ["-y", "@subly_fi/pay@0.8.0", "mcp"],
64
68
  "env": {
65
69
  "SUBLY_RELAYER_URL": "https://your-relayer.example.com",
66
70
  "SOLANA_RPC_URL": "https://your-mainnet-rpc.example.com",
@@ -73,7 +77,7 @@ Add this to the MCP configuration of your editor or agent host. Replace all exam
73
77
  }
74
78
  ```
75
79
 
76
- Tools: `list_subly_vaults`, `select_subly_vault`, `create_subly_setup_link`, `check_subly_setup`, `deposit_to_subly_vault`, `get_subly_yield_budget`, `withdraw_from_subly_vault`, `fetch_with_subly_payment`. Ask the agent to list vaults and follow owner setup before depositing. Each selected vault has its own mandate and accounting; changing selection never moves funds. Stdio stdout is reserved for MCP messages.
80
+ Tools: `list_subly_vaults`, `select_subly_vault`, `create_subly_setup_link`, `check_subly_setup`, `check_subly_vault_operation`, `deposit_to_subly_vault`, `get_subly_yield_budget`, `withdraw_from_subly_vault`, `fetch_with_subly_payment`. Ask the agent to list vaults and follow owner setup before depositing. Each selected vault has its own mandate and accounting; changing selection never moves funds. Stdio stdout is reserved for MCP messages.
77
81
 
78
82
  ## Configuration
79
83
 
@@ -99,8 +103,10 @@ Only sellers offering **Solana mainnet USDC `exact`** with `extra.feePayer` are
99
103
  - `doctor` performs read-only configuration, relayer/vault and RPC checks. It never signs or transacts; it does not prove vault safety or available yield.
100
104
  - A withdrawal preview failure is a refusal to sign. Check your RPC's simulation support and liquidity; do not disable transaction validation.
101
105
  - Preserve the pending-state JSON across restarts and upgrades. An `external_outcome_unknown` record blocks a second payment until you investigate the seller/facilitator outcome.
106
+ - When `fetch` stops while realizing yield, retry the same request with the same wallet, vault, relayer, method, body and headers. A saved withdrawal checkpoint resumes the original withdrawal and reuses its confirmed funds. It never prepares a replacement during recovery, even with `forceNewPayment`. If interruption occurred before the withdrawal ID was saved, or the original withdrawal ended unsuccessfully, reconcile that operation with the operator before starting another payment.
107
+ - Older pending records remain blocked for investigation because they do not identify a resumable withdrawal. Keep the state file; do not downgrade the client while a realization is pending.
102
108
  - Concurrent clients using the same state file serialize payments with a `.lock` file. After a crash, stop **all** clients using that file before removing only the stale `.lock`. Preserve the JSON. A new file or another machine cannot coordinate with the old one.
103
- - `submitted` means the transaction may still confirm. Poll the original intent ID / transaction instead of preparing another deposit or withdrawal.
109
+ - `submitted` means the transaction may still confirm. For a manual deposit or withdrawal, run `pay status <intentId>` or call `check_subly_vault_operation` with `intentId` instead of repeating the operation. Keep the original wallet, selected vault and relayer. An API `fetch` uses the saved recovery checkpoint described above.
104
110
  - Setup passkeys bind to the operator's domain. Use the original domain and device credential; follow the documented recovery delay if access is lost.
105
111
 
106
112
  [Full troubleshooting](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/troubleshooting.md) · [Support](https://github.com/SublyFi/subly-payment-protocol/blob/main/SUPPORT.md) · [Private security reports](https://github.com/SublyFi/subly-payment-protocol/security/advisories/new)
package/dist/budget.js CHANGED
@@ -1790,6 +1790,18 @@ async function assertWithdrawalPreview(input) {
1790
1790
  }
1791
1791
  }
1792
1792
 
1793
+ // ../../src/lib/canonical-json.ts
1794
+ import { createHash as createHash3 } from "node:crypto";
1795
+ function canonicalJson2(value) {
1796
+ return stableStringify(value);
1797
+ }
1798
+ function sha256HexOf(data) {
1799
+ return createHash3("sha256").update(data, "utf8").digest("hex");
1800
+ }
1801
+ function canonicalJsonHash(value) {
1802
+ return sha256HexOf(canonicalJson2(value));
1803
+ }
1804
+
1793
1805
  // ../../src/client/lookup-tables.ts
1794
1806
  import { fetchAllMaybeAddressLookupTable } from "@solana-program/address-lookup-table";
1795
1807
  import { address, getCompiledTransactionMessageDecoder as getCompiledTransactionMessageDecoder2 } from "@solana/kit";
@@ -1831,14 +1843,14 @@ async function fetchLookupTablesForTransaction(rpc, serializedTransaction) {
1831
1843
  }
1832
1844
 
1833
1845
  // ../../src/api/wallet-auth.ts
1834
- import { createHash as createHash3 } from "node:crypto";
1846
+ import { createHash as createHash4 } from "node:crypto";
1835
1847
  import bs587 from "bs58";
1836
1848
  import nacl3 from "tweetnacl";
1837
1849
  var WALLET_AUTH_WALLET_HEADER = "x-subly-wallet";
1838
1850
  var WALLET_AUTH_SIGNED_AT_HEADER = "x-subly-signed-at";
1839
1851
  var WALLET_AUTH_SIGNATURE_HEADER = "x-subly-signature";
1840
1852
  function sha256Hex(data) {
1841
- return createHash3("sha256").update(data, "utf8").digest("hex");
1853
+ return createHash4("sha256").update(data, "utf8").digest("hex");
1842
1854
  }
1843
1855
  function walletAuthMessage(params) {
1844
1856
  return new TextEncoder().encode(
@@ -1882,6 +1894,11 @@ var VaultFlowClientError = class extends Error {
1882
1894
  code;
1883
1895
  errorDetails;
1884
1896
  };
1897
+ function vaultOperationKind(intentId) {
1898
+ if (/^dep_[0-9a-f]{32}$/.test(intentId)) return "deposit";
1899
+ if (/^wdr_[0-9a-f]{32}$/.test(intentId)) return "withdrawal";
1900
+ throw new VaultFlowClientError("read", "intentId must be the original dep_ or wdr_ ID followed by 32 lowercase hexadecimal characters");
1901
+ }
1885
1902
  var VaultFlowClient = class {
1886
1903
  vault;
1887
1904
  rpc;
@@ -1984,9 +2001,36 @@ var VaultFlowClient = class {
1984
2001
  ...input.approvalId === void 0 ? {} : { approvalId: input.approvalId }
1985
2002
  }
1986
2003
  );
2004
+ this.assertPreparedWithdrawal(prepared, input);
2005
+ await input.onPrepared?.(prepared);
2006
+ return this.submitPreparedWithdrawal(prepared, input);
2007
+ }
2008
+ /** Reconcile or submit the original intent; never prepare a replacement. */
2009
+ async resumeWithdrawal(prepared, input) {
2010
+ this.assertPreparedWithdrawal(prepared, input);
2011
+ const current = await this.getJson(
2012
+ `/v1/withdrawals/${encodeURIComponent(prepared.withdrawalId)}`
2013
+ );
2014
+ if (current.withdrawalId !== prepared.withdrawalId || current.wallet !== this.signer.walletAddress || current.vault !== this.vault.address || current.requestedWithdrawRawUsdc !== input.amountRawUsdc.toString() || current.purpose !== (input.purpose ?? "normal") || canonicalJsonHash(current.paymentBinding ?? null) !== canonicalJsonHash(input.payment ?? null) || current.serializedTransaction !== prepared.serializedTransaction || current.preparedMessageHash !== prepared.signingIntent.preparedMessageHash || current.destinationUsdcAta !== prepared.destinationUsdcAta) {
2015
+ throw new VaultFlowClientError("read", "Saved withdrawal differs from the original operation; refusing to resume");
2016
+ }
2017
+ if (current.status === "prepared") {
2018
+ return this.submitPreparedWithdrawal(prepared, input);
2019
+ }
2020
+ if (!["submitted", "confirmed", "failed", "failed_not_submitted", "expired", "quarantined"].includes(current.status)) {
2021
+ throw new VaultFlowClientError("read", "Relayer returned an unknown withdrawal status");
2022
+ }
2023
+ return this.withdrawalOutcome(prepared, current);
2024
+ }
2025
+ assertPreparedWithdrawal(prepared, input) {
1987
2026
  if (prepared.signingIntent?.wallet !== this.signer.walletAddress || prepared.signingIntent.vault !== this.vault.address || prepared.requestedWithdrawRawUsdc !== input.amountRawUsdc.toString() || prepared.purpose !== (input.purpose ?? "normal") || input.purpose === "yield_realize" && prepared.signingIntent.allowFullExit) {
1988
2027
  throw new VaultFlowClientError("prepare", "Prepared withdrawal differs from the requested operation");
1989
2028
  }
2029
+ if (typeof prepared.withdrawalId !== "string" || prepared.withdrawalId.length === 0) {
2030
+ throw new VaultFlowClientError("prepare", "Prepared withdrawal has no withdrawal ID");
2031
+ }
2032
+ }
2033
+ async submitPreparedWithdrawal(prepared, input) {
1990
2034
  await assertWithdrawalPreview({
1991
2035
  rpc: this.rpc,
1992
2036
  serializedTransaction: prepared.serializedTransaction,
@@ -2000,6 +2044,7 @@ var VaultFlowClient = class {
2000
2044
  serializedTransaction: prepared.serializedTransaction,
2001
2045
  lookupTables: await this.lookupTablesFor(prepared.serializedTransaction)
2002
2046
  });
2047
+ input.onBeforeSubmit?.();
2003
2048
  let outcome = await this.postJson("submit", "/v1/withdrawals/submit", {
2004
2049
  withdrawalId: prepared.withdrawalId,
2005
2050
  serializedTransaction: signed.serializedTransaction,
@@ -2011,6 +2056,9 @@ var VaultFlowClient = class {
2011
2056
  outcome
2012
2057
  );
2013
2058
  }
2059
+ return this.withdrawalOutcome(prepared, outcome);
2060
+ }
2061
+ withdrawalOutcome(prepared, outcome) {
2014
2062
  return {
2015
2063
  withdrawalId: prepared.withdrawalId,
2016
2064
  status: outcome.status,
@@ -2021,6 +2069,40 @@ var VaultFlowClient = class {
2021
2069
  errorCode: outcome.errorCode ?? null
2022
2070
  };
2023
2071
  }
2072
+ /** Authenticated read/reconciliation only: never prepare, sign or submit a transaction. */
2073
+ async getOperationStatus(intentId) {
2074
+ const kind = vaultOperationKind(intentId);
2075
+ const raw = await this.getJson(`/v1/${kind === "deposit" ? "deposits" : "withdrawals"}/${intentId}?resubmit=false`);
2076
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
2077
+ throw new VaultFlowClientError("read", "Relayer returned an invalid operation status");
2078
+ }
2079
+ const record = raw;
2080
+ if (record[kind === "deposit" ? "depositId" : "withdrawalId"] !== intentId || record.wallet !== this.signer.walletAddress || record.vault !== this.vault.address) {
2081
+ throw new VaultFlowClientError("read", "Operation does not match the requested ID, current wallet or selected vault; use the original wallet, vault and relayer");
2082
+ }
2083
+ const requested = record[kind === "deposit" ? "amountRawUsdc" : "requestedWithdrawRawUsdc"];
2084
+ const actual = record[kind === "deposit" ? "actualDepositRawUsdc" : "actualWithdrawRawUsdc"];
2085
+ if (typeof record.status !== "string" || !["prepared", "submitted", "confirmed", "failed", "expired", "failed_not_submitted"].includes(record.status) || typeof requested !== "string" || !/^\d+$/.test(requested) || actual !== null && (typeof actual !== "string" || !/^\d+$/.test(actual)) || record.txSignature !== null && (typeof record.txSignature !== "string" || record.txSignature.length === 0) || record.errorCode !== null && typeof record.errorCode !== "string" || record.status === "confirmed" && (actual === null || record.txSignature === null)) {
2086
+ throw new VaultFlowClientError("read", "Relayer returned incomplete or invalid operation status fields");
2087
+ }
2088
+ const status = record.status;
2089
+ const nextAction = status === "confirmed" ? "done" : status === "submitted" || status === "prepared" ? "check_again" : "reconcile_with_operator";
2090
+ const message = status === "confirmed" ? `The original ${kind} is confirmed.` : status === "submitted" ? "The original transaction is still confirming. Check this same intent ID again; do not repeat the deposit or withdrawal." : status === "prepared" ? "The original intent is prepared. This status check does not submit it. Check the same ID again or ask the operator to reconcile it before starting another operation." : "The original operation ended without a confirmed result. Reconcile its intent ID and transaction with the operator before starting another operation.";
2091
+ return {
2092
+ intentId,
2093
+ kind,
2094
+ wallet: this.signer.walletAddress,
2095
+ vault: this.vault.address,
2096
+ status,
2097
+ requestedAmountRawUsdc: requested,
2098
+ actualAmountRawUsdc: actual,
2099
+ txSignature: record.txSignature,
2100
+ errorCode: record.errorCode,
2101
+ stillConfirming: status === "submitted",
2102
+ nextAction,
2103
+ message
2104
+ };
2105
+ }
2024
2106
  /**
2025
2107
  * Reads the yield budget. Syncs the relayer's ledger from chain first (so
2026
2108
  * yield accrued since the last sync shows up); the sync is best-effort and
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ const here = dirname(fileURLToPath(import.meta.url));
6
6
  const TARGETS = {
7
7
  mcp: "mcp-server.js", fetch: "pay.js", deposit: "deposit.js", withdraw: "withdraw.js",
8
8
  "setup-link": "setup-link.js", "setup-status": "setup-status.js",
9
- doctor: "doctor.js", budget: "budget.js", vaults: "vaults.js"
9
+ doctor: "doctor.js", budget: "budget.js", vaults: "vaults.js", status: "status.js"
10
10
  };
11
11
  const HELP = `Subly — x402 payments from Kamino USDC vault yield
12
12
 
@@ -18,6 +18,7 @@ Usage: pay <command> [arguments]
18
18
  deposit <rawUSDC> [approvalId] Deposit into the selected vault (real funds)
19
19
  budget Refresh and read the selected vault's yield budget
20
20
  withdraw <rawUSDC> [approvalId] Withdraw to the agent wallet (real funds)
21
+ status <dep_...|wdr_...> Check the original deposit/withdrawal (no new transaction)
21
22
  fetch <URL> Pay a compatible x402 API within the configured cap
22
23
  mcp Start the stdio MCP server
23
24
  --version Print package version
package/dist/deposit.js CHANGED
@@ -1886,6 +1886,18 @@ async function assertWithdrawalPreview(input) {
1886
1886
  }
1887
1887
  }
1888
1888
 
1889
+ // ../../src/lib/canonical-json.ts
1890
+ import { createHash as createHash4 } from "node:crypto";
1891
+ function canonicalJson2(value) {
1892
+ return stableStringify(value);
1893
+ }
1894
+ function sha256HexOf(data) {
1895
+ return createHash4("sha256").update(data, "utf8").digest("hex");
1896
+ }
1897
+ function canonicalJsonHash(value) {
1898
+ return sha256HexOf(canonicalJson2(value));
1899
+ }
1900
+
1889
1901
  // ../../src/client/lookup-tables.ts
1890
1902
  import { fetchAllMaybeAddressLookupTable } from "@solana-program/address-lookup-table";
1891
1903
  import { address, getCompiledTransactionMessageDecoder as getCompiledTransactionMessageDecoder2 } from "@solana/kit";
@@ -1941,6 +1953,11 @@ var VaultFlowClientError = class extends Error {
1941
1953
  code;
1942
1954
  errorDetails;
1943
1955
  };
1956
+ function vaultOperationKind(intentId) {
1957
+ if (/^dep_[0-9a-f]{32}$/.test(intentId)) return "deposit";
1958
+ if (/^wdr_[0-9a-f]{32}$/.test(intentId)) return "withdrawal";
1959
+ throw new VaultFlowClientError("read", "intentId must be the original dep_ or wdr_ ID followed by 32 lowercase hexadecimal characters");
1960
+ }
1944
1961
  var VaultFlowClient = class {
1945
1962
  vault;
1946
1963
  rpc;
@@ -2043,9 +2060,36 @@ var VaultFlowClient = class {
2043
2060
  ...input.approvalId === void 0 ? {} : { approvalId: input.approvalId }
2044
2061
  }
2045
2062
  );
2063
+ this.assertPreparedWithdrawal(prepared, input);
2064
+ await input.onPrepared?.(prepared);
2065
+ return this.submitPreparedWithdrawal(prepared, input);
2066
+ }
2067
+ /** Reconcile or submit the original intent; never prepare a replacement. */
2068
+ async resumeWithdrawal(prepared, input) {
2069
+ this.assertPreparedWithdrawal(prepared, input);
2070
+ const current = await this.getJson(
2071
+ `/v1/withdrawals/${encodeURIComponent(prepared.withdrawalId)}`
2072
+ );
2073
+ if (current.withdrawalId !== prepared.withdrawalId || current.wallet !== this.signer.walletAddress || current.vault !== this.vault.address || current.requestedWithdrawRawUsdc !== input.amountRawUsdc.toString() || current.purpose !== (input.purpose ?? "normal") || canonicalJsonHash(current.paymentBinding ?? null) !== canonicalJsonHash(input.payment ?? null) || current.serializedTransaction !== prepared.serializedTransaction || current.preparedMessageHash !== prepared.signingIntent.preparedMessageHash || current.destinationUsdcAta !== prepared.destinationUsdcAta) {
2074
+ throw new VaultFlowClientError("read", "Saved withdrawal differs from the original operation; refusing to resume");
2075
+ }
2076
+ if (current.status === "prepared") {
2077
+ return this.submitPreparedWithdrawal(prepared, input);
2078
+ }
2079
+ if (!["submitted", "confirmed", "failed", "failed_not_submitted", "expired", "quarantined"].includes(current.status)) {
2080
+ throw new VaultFlowClientError("read", "Relayer returned an unknown withdrawal status");
2081
+ }
2082
+ return this.withdrawalOutcome(prepared, current);
2083
+ }
2084
+ assertPreparedWithdrawal(prepared, input) {
2046
2085
  if (prepared.signingIntent?.wallet !== this.signer.walletAddress || prepared.signingIntent.vault !== this.vault.address || prepared.requestedWithdrawRawUsdc !== input.amountRawUsdc.toString() || prepared.purpose !== (input.purpose ?? "normal") || input.purpose === "yield_realize" && prepared.signingIntent.allowFullExit) {
2047
2086
  throw new VaultFlowClientError("prepare", "Prepared withdrawal differs from the requested operation");
2048
2087
  }
2088
+ if (typeof prepared.withdrawalId !== "string" || prepared.withdrawalId.length === 0) {
2089
+ throw new VaultFlowClientError("prepare", "Prepared withdrawal has no withdrawal ID");
2090
+ }
2091
+ }
2092
+ async submitPreparedWithdrawal(prepared, input) {
2049
2093
  await assertWithdrawalPreview({
2050
2094
  rpc: this.rpc,
2051
2095
  serializedTransaction: prepared.serializedTransaction,
@@ -2059,6 +2103,7 @@ var VaultFlowClient = class {
2059
2103
  serializedTransaction: prepared.serializedTransaction,
2060
2104
  lookupTables: await this.lookupTablesFor(prepared.serializedTransaction)
2061
2105
  });
2106
+ input.onBeforeSubmit?.();
2062
2107
  let outcome = await this.postJson("submit", "/v1/withdrawals/submit", {
2063
2108
  withdrawalId: prepared.withdrawalId,
2064
2109
  serializedTransaction: signed.serializedTransaction,
@@ -2070,6 +2115,9 @@ var VaultFlowClient = class {
2070
2115
  outcome
2071
2116
  );
2072
2117
  }
2118
+ return this.withdrawalOutcome(prepared, outcome);
2119
+ }
2120
+ withdrawalOutcome(prepared, outcome) {
2073
2121
  return {
2074
2122
  withdrawalId: prepared.withdrawalId,
2075
2123
  status: outcome.status,
@@ -2080,6 +2128,40 @@ var VaultFlowClient = class {
2080
2128
  errorCode: outcome.errorCode ?? null
2081
2129
  };
2082
2130
  }
2131
+ /** Authenticated read/reconciliation only: never prepare, sign or submit a transaction. */
2132
+ async getOperationStatus(intentId) {
2133
+ const kind = vaultOperationKind(intentId);
2134
+ const raw = await this.getJson(`/v1/${kind === "deposit" ? "deposits" : "withdrawals"}/${intentId}?resubmit=false`);
2135
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
2136
+ throw new VaultFlowClientError("read", "Relayer returned an invalid operation status");
2137
+ }
2138
+ const record = raw;
2139
+ if (record[kind === "deposit" ? "depositId" : "withdrawalId"] !== intentId || record.wallet !== this.signer.walletAddress || record.vault !== this.vault.address) {
2140
+ throw new VaultFlowClientError("read", "Operation does not match the requested ID, current wallet or selected vault; use the original wallet, vault and relayer");
2141
+ }
2142
+ const requested = record[kind === "deposit" ? "amountRawUsdc" : "requestedWithdrawRawUsdc"];
2143
+ const actual = record[kind === "deposit" ? "actualDepositRawUsdc" : "actualWithdrawRawUsdc"];
2144
+ if (typeof record.status !== "string" || !["prepared", "submitted", "confirmed", "failed", "expired", "failed_not_submitted"].includes(record.status) || typeof requested !== "string" || !/^\d+$/.test(requested) || actual !== null && (typeof actual !== "string" || !/^\d+$/.test(actual)) || record.txSignature !== null && (typeof record.txSignature !== "string" || record.txSignature.length === 0) || record.errorCode !== null && typeof record.errorCode !== "string" || record.status === "confirmed" && (actual === null || record.txSignature === null)) {
2145
+ throw new VaultFlowClientError("read", "Relayer returned incomplete or invalid operation status fields");
2146
+ }
2147
+ const status = record.status;
2148
+ const nextAction = status === "confirmed" ? "done" : status === "submitted" || status === "prepared" ? "check_again" : "reconcile_with_operator";
2149
+ const message = status === "confirmed" ? `The original ${kind} is confirmed.` : status === "submitted" ? "The original transaction is still confirming. Check this same intent ID again; do not repeat the deposit or withdrawal." : status === "prepared" ? "The original intent is prepared. This status check does not submit it. Check the same ID again or ask the operator to reconcile it before starting another operation." : "The original operation ended without a confirmed result. Reconcile its intent ID and transaction with the operator before starting another operation.";
2150
+ return {
2151
+ intentId,
2152
+ kind,
2153
+ wallet: this.signer.walletAddress,
2154
+ vault: this.vault.address,
2155
+ status,
2156
+ requestedAmountRawUsdc: requested,
2157
+ actualAmountRawUsdc: actual,
2158
+ txSignature: record.txSignature,
2159
+ errorCode: record.errorCode,
2160
+ stillConfirming: status === "submitted",
2161
+ nextAction,
2162
+ message
2163
+ };
2164
+ }
2083
2165
  /**
2084
2166
  * Reads the yield budget. Syncs the relayer's ledger from chain first (so
2085
2167
  * yield accrued since the last sync shows up); the sync is best-effort and
@@ -2418,12 +2500,12 @@ if (submitted.txSignature !== null) {
2418
2500
  }
2419
2501
  if (submitted.status === "submitted") {
2420
2502
  fail(
2421
- `[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`
2503
+ `[deposit] broadcast but not yet confirmed \u2014 it may still land. Do NOT resubmit; run pay status ${submitted.depositId} with the same wallet, vault and relayer first`
2422
2504
  );
2423
2505
  }
2424
2506
  if (submitted.status !== "confirmed") {
2425
2507
  fail(
2426
- `[deposit] not confirmed (errorCode=${submitted.errorCode}); check GET /v1/deposits/${submitted.depositId} and the relayer logs`
2508
+ `[deposit] not confirmed (errorCode=${submitted.errorCode}); run pay status ${submitted.depositId} and ask the operator to reconcile it`
2427
2509
  );
2428
2510
  }
2429
2511
  console.log(