@subly_fi/pay 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -46
- package/dist/budget.js +84 -2
- package/dist/cli.js +10 -6
- package/dist/deposit.js +94 -5
- package/dist/mcp-server.js +1548 -1238
- package/dist/pay.js +1390 -1119
- package/dist/setup-link.js +92 -3
- package/dist/setup-status.js +8 -1
- package/dist/status.js +2366 -0
- package/dist/withdraw.js +95 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,70 +2,216 @@
|
|
|
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.
|
|
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
|
+
|
|
7
|
+
[日本語の導入ガイド](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/getting-started.ja.md) · [Set up with an AI assistant](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/ai-setup-prompts.md)
|
|
6
8
|
|
|
7
9
|
## Quick start
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Follow the steps in order. Every Subly command below uses `npx`; no repository clone or global `pay` installation is needed. Replace example URLs, paths and IDs with your own values.
|
|
12
|
+
|
|
13
|
+
### 1. Prepare the software and endpoints
|
|
14
|
+
|
|
15
|
+
- Install **Node.js 24+ with npm** from the [official download page](https://nodejs.org/en/download). Open a new terminal and check `node --version` and `npm --version`.
|
|
16
|
+
- Obtain a **trusted relayer's HTTPS URL**, running version 0.8.0 or newer. This guide supplies no guaranteed public endpoint. To operate one yourself, use the [operator guide](https://github.com/SublyFi/subly-payment-protocol/tree/main/deploy).
|
|
17
|
+
- Obtain a **Solana mainnet RPC URL** supporting transaction simulation with inner instructions. Keep RPC credentials in local configuration, not public issues or chat.
|
|
18
|
+
- Use a **dedicated agent wallet** with USDC on Solana mainnet. This wallet holds funds and signs transactions. The human owner's passkey or separate wallet approves spending controls. Supported custody signers are described under [Configuration](#configuration).
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npx -y @subly_fi/pay@0.8.1 --version
|
|
22
|
+
npx -y @subly_fi/pay@0.8.1 --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The version should be `0.8.1`. A help screen alone does not check your wallet or endpoints.
|
|
26
|
+
|
|
27
|
+
### 2. Prepare and fund the agent wallet
|
|
28
|
+
|
|
29
|
+
If you already have a dedicated **64-byte Solana JSON keypair**, use its absolute file path and skip creation. Do not overwrite an existing keypair.
|
|
30
|
+
|
|
31
|
+
To create one, install the Solana CLI using its [official installation guide](https://solana.com/docs/intro/installation). Subly does not install that CLI. On Windows, the official guide uses WSL: you can keep this entire terminal workflow in WSL and use the macOS/Linux examples below. Native PowerShell settings are also shown for a keypair accessible to Windows.
|
|
32
|
+
|
|
33
|
+
Run wallet creation yourself in a **private terminal**, not through an AI tool that records command output: `solana-keygen new` displays the recovery phrase. Do not copy its output into chat. In a macOS, Linux or WSL terminal:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
(
|
|
37
|
+
set -eu
|
|
38
|
+
command -v solana-keygen >/dev/null
|
|
39
|
+
umask 077
|
|
40
|
+
mkdir -p "$HOME/.subly"
|
|
41
|
+
if [ -e "$HOME/.subly/agent.json" ]; then
|
|
42
|
+
printf '%s\n' 'Keypair already exists; use it or choose another path.' >&2
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
solana-keygen new --outfile "$HOME/.subly/agent.json"
|
|
46
|
+
chmod 600 "$HOME/.subly/agent.json"
|
|
47
|
+
solana-keygen pubkey "$HOME/.subly/agent.json"
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The last command prints the **public receiving address**. Save the recovery phrase privately; never paste it, the JSON file contents or a private key into an AI chat. The keypair file contains signing secrets even if wallet creation asked for a recovery passphrase. Restrict it to your OS account; for an existing Windows file, review its Security properties.
|
|
52
|
+
|
|
53
|
+
Send **USDC on Solana mainnet** to that public address from your existing wallet or exchange, and confirm arrival. The example below deposits **1.01 USDC**; the selected vault's minimum may differ. Subly does not fund wallets. The relayer sponsor pays vault transaction fees; the seller's facilitator supplies the final API payment's fee payer. Ask the operator if sponsorship is unavailable.
|
|
54
|
+
|
|
55
|
+
### 3. Configure this terminal and check it
|
|
56
|
+
|
|
57
|
+
Choose one environment example. Settings apply to the current terminal; a new terminal or desktop MCP host needs its own configuration. Keep the same wallet, selected vault, relayer and pending-state path when continuing a payment. RPC URLs often contain API keys: enter the URL yourself at the hidden prompt in your private terminal, not in an AI chat or AI tool input. These examples keep it out of the command text and shell history.
|
|
58
|
+
|
|
59
|
+
macOS/Linux/WSL (Bash or zsh):
|
|
10
60
|
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
61
|
+
```sh
|
|
62
|
+
export SUBLY_RELAYER_URL="https://your-relayer.example.com"
|
|
63
|
+
printf 'Solana mainnet RPC URL (hidden): '
|
|
64
|
+
read -r -s SOLANA_RPC_URL
|
|
65
|
+
printf '\n'
|
|
66
|
+
export SOLANA_RPC_URL
|
|
67
|
+
export SUBLY_DEMO_AGENT_KEYPAIR_PATH="$HOME/.subly/agent.json"
|
|
68
|
+
export SUBLY_MCP_STATE_PATH="$HOME/.subly/standard-x402-pending.json"
|
|
18
69
|
```
|
|
19
70
|
|
|
20
|
-
|
|
71
|
+
Windows PowerShell, with a keypair already stored at this Windows path:
|
|
21
72
|
|
|
22
|
-
|
|
23
|
-
|
|
73
|
+
```powershell
|
|
74
|
+
$env:SUBLY_RELAYER_URL = "https://your-relayer.example.com"
|
|
75
|
+
$sublyRpcSecret = Read-Host "Solana mainnet RPC URL" -AsSecureString
|
|
76
|
+
$env:SOLANA_RPC_URL = [System.Net.NetworkCredential]::new("", $sublyRpcSecret).Password
|
|
77
|
+
Remove-Variable sublyRpcSecret
|
|
78
|
+
$env:SUBLY_DEMO_AGENT_KEYPAIR_PATH = "$env:USERPROFILE\.subly\agent.json"
|
|
79
|
+
$env:SUBLY_MCP_STATE_PATH = "$env:USERPROFILE\.subly\standard-x402-pending.json"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use absolute paths valid where the client runs. Windows and WSL home directories differ; switching must not silently create a second pending-state file for the same wallet. If PowerShell blocks `npx.ps1`, invoke `npx.cmd` with the same arguments instead of changing the machine's execution policy.
|
|
24
83
|
|
|
25
|
-
|
|
26
|
-
npx -y @subly_fi/pay@0.7.3 setup-link --initial-deposit 1010000
|
|
27
|
-
```
|
|
84
|
+
For a custom operator catalogue, review and install the file, then set `SUBLY_VAULTS_FILE` to its absolute path using your shell's syntax above. `SUBLY_VAULT_ADDRESS` selects one listed vault. Configure this before checking the relayer; never install transaction trust anchors merely because a remote response says to.
|
|
28
85
|
|
|
29
|
-
|
|
30
|
-
|
|
86
|
+
```sh
|
|
87
|
+
npx -y @subly_fi/pay@0.8.1 doctor
|
|
88
|
+
npx -y @subly_fi/pay@0.8.1 vaults
|
|
89
|
+
```
|
|
31
90
|
|
|
32
|
-
|
|
33
|
-
npx -y @subly_fi/pay@0.7.3 setup-status <sessionId>
|
|
34
|
-
npx -y @subly_fi/pay@0.7.3 deposit 1010000
|
|
35
|
-
npx -y @subly_fi/pay@0.7.3 budget
|
|
36
|
-
```
|
|
91
|
+
Continue when `doctor` returns `"ok": true` and the selected local vault matches the operator's catalogue. It checks configuration and reachability, not balance, simulation support, available yield or vault safety. Review the vault's curator, fees, minimum deposit and liquidity with the operator. `vaults` prints trusted local metadata, not a live balance.
|
|
37
92
|
|
|
38
|
-
4.
|
|
93
|
+
### 4. Register the owner and approve the first deposit
|
|
39
94
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
95
|
+
Use the same raw amount for setup and deposit. Amounts are six-decimal USDC integers: `1000000` = 1 USDC, `1010000` = 1.01 USDC, `10000` = 0.01 USDC.
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
npx -y @subly_fi/pay@0.8.1 setup-link --initial-deposit 1010000
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The result contains `sessionId` and `setupUrl`. Open `setupUrl` on your device, review the wallet, vault and limits, then approve with your passkey or owner wallet. Links expire in 10 minutes. Treat them as private capabilities: the first person completing initial setup becomes the owner for that wallet/vault.
|
|
102
|
+
|
|
103
|
+
After approval, **return to this terminal**, replacing the placeholder with the returned ID:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npx -y @subly_fi/pay@0.8.1 setup-status st_YOUR_SESSION_ID
|
|
107
|
+
```
|
|
43
108
|
|
|
44
|
-
|
|
109
|
+
Continue only on `"status": "completed"`. `pending` means approval is unfinished; `expired` means create a fresh setup link. On first registration, `initialDepositApproval` should be present and approved. Deposit promptly: it lasts about 15 minutes. Browser approval saves authorization; it does not run a CLI command. With MCP, tell the agent that approval is complete so it can check and continue.
|
|
45
110
|
|
|
46
|
-
|
|
47
|
-
npx -y @subly_fi/pay@0.7.3 withdraw 1000000
|
|
48
|
-
```
|
|
111
|
+
Review policy options **before first registration**:
|
|
49
112
|
|
|
50
|
-
|
|
113
|
+
```sh
|
|
114
|
+
npx -y @subly_fi/pay@0.8.1 setup-link --help
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Current limitation:** another setup link does not update an existing active or revoked passkey mandate. CLI/MCP has no passkey policy-change, owner-recovery or revoke-reversal workflow. Ask the operator about the supported low-level procedure; do not assume a new link or credential can unlock it. Revocation also blocks relayer withdrawals. An existing wallet owner can re-sign where allowed, but replacement requires a separate deposit approval.
|
|
118
|
+
|
|
119
|
+
### 5. Deposit and inspect the budget
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
npx -y @subly_fi/pay@0.8.1 deposit 1010000
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Success prints `status: confirmed`, a `depositId`, a transaction link and the confirmed amount. Keep the ID. For `submitted`, use the [status procedure](#check-an-interrupted-deposit-or-withdrawal); the transaction may still land, so do not deposit again.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
npx -y @subly_fi/pay@0.8.1 budget
|
|
129
|
+
```
|
|
51
130
|
|
|
52
|
-
A
|
|
131
|
+
Inspect `spendableYieldRawUsdc`. A new deposit does **not** immediately provide a payment budget; principal is not spendable yield. Wait until yield covers the API price and vault fees, then check again. There is no guaranteed waiting time: performance, fees, deposited amount and liquidity matter. The 1.01 USDC example demonstrates setup and deposit, not an immediate paid call. A budget read can return the last synced view if refresh fails; live payment checks still decide whether it can proceed.
|
|
132
|
+
|
|
133
|
+
### 6. Request a compatible paid API
|
|
134
|
+
|
|
135
|
+
Obtain a real paid URL from its seller; the hostname below is a placeholder. Supported offers are **Solana mainnet USDC `exact`** with `extra.feePayer`. EVM, other tokens and unsponsored rails are refused. A seller name alone does not prove compatibility.
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npx -y @subly_fi/pay@0.8.1 fetch https://seller.example.com/paid-resource
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The default client cap is **0.01 USDC**. An explicit cap of 0.02 USDC looks like this:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
npx -y @subly_fi/pay@0.8.1 fetch https://seller.example.com/paid-resource 20000
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The owner policy may impose stricter limits. A successful paid call returns `"paid": true`, an HTTP 2xx `status` and the API response `body`. `paid: false` is not a confirmed paid call; read its reason or HTTP response. If the endpoint did not require payment, no payment was made.
|
|
148
|
+
|
|
149
|
+
For `approval_required`, open the returned `approveUrl`, approve, return to the terminal, and repeat the **same URL, request and cap** with the returned approval ID:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
npx -y @subly_fi/pay@0.8.1 fetch https://seller.example.com/paid-resource 20000 apr_YOUR_APPROVAL_ID
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Replace the placeholder ID. For MCP, tell the agent approval is complete and ask it to resume the same operation with that ID. An unknown payment outcome is not an approval retry; follow [Recovery and troubleshooting](#recovery-and-troubleshooting).
|
|
156
|
+
|
|
157
|
+
### 7. Withdraw to the agent wallet
|
|
158
|
+
|
|
159
|
+
A withdrawal can include principal and is subject to liquidity, fees and owner policy. This requests 1 USDC back to the **same agent wallet**:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
npx -y @subly_fi/pay@0.8.1 withdraw 1000000
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Success prints `status: confirmed`, a `withdrawalId`, a transaction link and the confirmed amount. An approval-required result includes `approveUrl` and `approvalId`. After approval, repeat the same amount with that ID:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
npx -y @subly_fi/pay@0.8.1 withdraw 1000000 apr_YOUR_APPROVAL_ID
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Later deposits use the same approval pattern:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
npx -y @subly_fi/pay@0.8.1 deposit 1010000 apr_YOUR_APPROVAL_ID
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Only use an ID issued for that exact operation. Moving withdrawn funds onward to another wallet is a separate action outside this CLI.
|
|
178
|
+
|
|
179
|
+
### Check an interrupted deposit or withdrawal
|
|
180
|
+
|
|
181
|
+
Keep the original `depositId` (`dep_...`) or `withdrawalId` (`wdr_...`). Replace this placeholder with its full ID:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
npx -y @subly_fi/pay@0.8.1 status wdr_YOUR_WITHDRAWAL_ID
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Use the original wallet, selected vault and relayer. Status requires **relayer 0.8.0 or newer** and reconciles with `?resubmit=false`: it does not prepare, sign or send another transaction. Wallet-auth message signing is required, but no client RPC call is needed.
|
|
188
|
+
|
|
189
|
+
| Result | What to do |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `confirmed` / `nextAction: done` | The original operation is confirmed; inspect `actualAmountRawUsdc` and `txSignature`. |
|
|
192
|
+
| `submitted` / `nextAction: check_again` | Check the **same ID** later; do not repeat the deposit or withdrawal. |
|
|
193
|
+
| `prepared` | Status does not submit it. Keep the ID and ask the operator to reconcile before a new operation. |
|
|
194
|
+
| `nextAction: reconcile_with_operator` | Keep the ID and error code; ask the operator to reconcile the failed or expired operation. |
|
|
195
|
+
|
|
196
|
+
Exit zero means the status lookup worked, even if the operation is pending or failed. Inspect `status` and `nextAction`. An interrupted API payment uses its saved pending-state checkpoint instead, as described below.
|
|
53
197
|
|
|
54
198
|
## MCP configuration
|
|
55
199
|
|
|
56
|
-
|
|
200
|
+
Use your host's **documented MCP configuration format**. This JSON is for hosts accepting an `mcpServers` object; it is not universal. Codex uses its own MCP settings/configuration: translate the command, arguments and environment into that interface. The [AI setup prompts](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/ai-setup-prompts.md) help you configure the selected host.
|
|
201
|
+
|
|
202
|
+
Replace every example value. Use the **same absolute pending-state path as the CLI** for the same wallet. Shell exports may not reach a desktop app; supply variables to the MCP process itself. JSON does not expand `$HOME` or `$env:USERPROFILE`. Use paths such as `/Users/your-name/...`, `/home/your-name/...`, or escaped Windows paths such as `C:\\Users\\your-name\\.subly\\agent.json`.
|
|
57
203
|
|
|
58
204
|
```json
|
|
59
205
|
{
|
|
60
206
|
"mcpServers": {
|
|
61
207
|
"subly": {
|
|
62
208
|
"command": "npx",
|
|
63
|
-
"args": ["-y", "@subly_fi/pay@0.
|
|
209
|
+
"args": ["-y", "@subly_fi/pay@0.8.1", "mcp"],
|
|
64
210
|
"env": {
|
|
65
211
|
"SUBLY_RELAYER_URL": "https://your-relayer.example.com",
|
|
66
212
|
"SOLANA_RPC_URL": "https://your-mainnet-rpc.example.com",
|
|
67
|
-
"SUBLY_DEMO_AGENT_KEYPAIR_PATH": "/absolute/path/to/agent.json",
|
|
68
|
-
"SUBLY_MCP_STATE_PATH": "/absolute/path/to/
|
|
213
|
+
"SUBLY_DEMO_AGENT_KEYPAIR_PATH": "/absolute/path/to/.subly/agent.json",
|
|
214
|
+
"SUBLY_MCP_STATE_PATH": "/absolute/path/to/.subly/standard-x402-pending.json",
|
|
69
215
|
"SUBLY_MCP_MAX_AMOUNT_RAW_USDC": "10000"
|
|
70
216
|
}
|
|
71
217
|
}
|
|
@@ -73,7 +219,9 @@ Add this to the MCP configuration of your editor or agent host. Replace all exam
|
|
|
73
219
|
}
|
|
74
220
|
```
|
|
75
221
|
|
|
76
|
-
|
|
222
|
+
Restart the host after configuration changes and confirm it exposes all nine Subly tools. Some Windows hosts require a documented command wrapper for `npx.cmd`; follow the host's instructions rather than assuming the Unix launcher works unchanged.
|
|
223
|
+
|
|
224
|
+
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
225
|
|
|
78
226
|
## Configuration
|
|
79
227
|
|
|
@@ -82,26 +230,27 @@ Tools: `list_subly_vaults`, `select_subly_vault`, `create_subly_setup_link`, `ch
|
|
|
82
230
|
| `SUBLY_RELAYER_URL` | Chosen operator's HTTPS URL. Set explicitly; the historical demo fallback has no availability promise. |
|
|
83
231
|
| `SOLANA_RPC_URL` | Your trusted mainnet RPC; fallback is the rate-limited public mainnet RPC. Used to verify lookup tables and simulate withdrawals before signing. |
|
|
84
232
|
| `SUBLY_DEMO_AGENT_KEYPAIR_PATH` | Local Solana 64-byte JSON keypair; the historical `DEMO` name also applies in production. |
|
|
85
|
-
| `SUBLY_DEMO_AGENT_KEYPAIR` | Alternative base58 64-byte secret; if set, takes precedence over the file. Avoid putting secrets in shell history. |
|
|
233
|
+
| `SUBLY_DEMO_AGENT_KEYPAIR` | Alternative base58 64-byte secret; if set, takes precedence over the file. Avoid putting secrets in shell history or chat. |
|
|
86
234
|
| `SUBLY_SIGNER_PROVIDER` | `local` (default), `circle` or `privy`. |
|
|
87
235
|
| `SUBLY_VAULTS_FILE` | Reviewed local vault catalogue. Without it, the built-in vault is used. |
|
|
88
236
|
| `SUBLY_VAULT_ADDRESS` | Selected catalogue entry (or custom single-vault address with matching share mint/farm settings). |
|
|
89
237
|
| `SUBLY_MCP_MAX_AMOUNT_RAW_USDC` | Client payment cap; default `10000`. |
|
|
90
|
-
| `SUBLY_MCP_STATE_PATH` | Persistent pending-payment file. Default `~/.subly/standard-x402-pending.json`. Use one shared file for all clients paying from the same wallet. |
|
|
238
|
+
| `SUBLY_MCP_STATE_PATH` | Persistent pending-payment file. Default `~/.subly/standard-x402-pending.json`. Use one shared file for all CLI/MCP clients paying from the same wallet on this machine. Different machines do not coordinate payments. |
|
|
91
239
|
| `SUBLY_PAY_METHOD` / `SUBLY_PAY_BODY` | Optional HTTP method and body for CLI `fetch`; MCP accepts these as tool arguments. |
|
|
92
240
|
|
|
93
|
-
For Circle configure `CIRCLE_API_KEY`, `CIRCLE_ENTITY_SECRET`, `CIRCLE_WALLET_ID`. For Privy use `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `PRIVY_WALLET_ID`, and `PRIVY_AUTHORIZATION_KEY` when required by wallet ownership. Each accepts a `SUBLY_` prefix which takes precedence.
|
|
241
|
+
For Circle set `SUBLY_SIGNER_PROVIDER=circle` and configure `CIRCLE_API_KEY`, `CIRCLE_ENTITY_SECRET`, `CIRCLE_WALLET_ID`. For Privy set `SUBLY_SIGNER_PROVIDER=privy` and use `PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `PRIVY_WALLET_ID`, and `PRIVY_AUTHORIZATION_KEY` when required by wallet ownership. Each accepts a `SUBLY_` prefix which takes precedence. These providers need a Solana mainnet wallet, not an EVM wallet; a local keypair is not required. The [provider implementation notes](https://github.com/SublyFi/subly-payment-protocol/blob/main/docs/agent-wallet-providers.md) describe supported transports and unimplemented proposals; they are not a wallet-creation tutorial.
|
|
94
242
|
|
|
95
243
|
Only sellers offering **Solana mainnet USDC `exact`** with `extra.feePayer` are supported. EVM, unsupported tokens and unsponsored rails are refused. A supported seller does not need a Subly integration. Yield realization and x402 payment are separate transactions: if the payment fails after realization, USDC may remain in the agent wallet.
|
|
96
244
|
|
|
97
245
|
## Recovery and troubleshooting
|
|
98
246
|
|
|
99
|
-
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
- Concurrent clients
|
|
103
|
-
-
|
|
104
|
-
-
|
|
247
|
+
- Preserve pending-state JSON across restarts, upgrades and CLI/MCP changes. An `external_outcome_unknown` record blocks another payment until you investigate the seller/facilitator outcome. Never delete the file to bypass it.
|
|
248
|
+
- For interrupted yield realization, retry the same request with the same wallet, vault, relayer, method, body and headers. A saved checkpoint resumes the original withdrawal and reuses its confirmed funds; even `forceNewPayment` cannot replace an unfinished realization. If no withdrawal ID was saved or it ended unsuccessfully, ask the operator to reconcile it before a new payment.
|
|
249
|
+
- Older pending records without a resumable withdrawal remain blocked for investigation. Keep the file; do not downgrade while a realization is pending.
|
|
250
|
+
- Concurrent clients sharing the state file serialize payments with a `.lock` file. After a crash, stop **all** clients using it before removing only a stale `.lock`. Preserve the JSON. Another path or machine cannot coordinate with the old file.
|
|
251
|
+
- For an interrupted manual deposit or withdrawal, use `npx -y @subly_fi/pay@0.8.1 status <intentId>` or MCP `check_subly_vault_operation` with the original ID and configuration, rather than repeating the financial operation.
|
|
252
|
+
- A withdrawal preview failure is a refusal to sign. Check RPC simulation support and liquidity; do not disable validation.
|
|
253
|
+
- Passkeys bind to the operator's domain. Use the original domain and credential. Lost-passkey recovery and existing passkey policy changes have no CLI/MCP command; contact the operator before attempting low-level recovery.
|
|
105
254
|
|
|
106
255
|
[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)
|
|
107
256
|
|
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
|
|
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
|
|
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
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const manifest = existsSync(join(here, "package.json")) ? join(here, "package.json") : join(here, "..", "package.json");
|
|
7
|
+
const version = JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
8
|
+
const command = `npx -y @subly_fi/pay@${version}`;
|
|
6
9
|
const TARGETS = {
|
|
7
10
|
mcp: "mcp-server.js", fetch: "pay.js", deposit: "deposit.js", withdraw: "withdraw.js",
|
|
8
11
|
"setup-link": "setup-link.js", "setup-status": "setup-status.js",
|
|
9
|
-
doctor: "doctor.js", budget: "budget.js", vaults: "vaults.js"
|
|
12
|
+
doctor: "doctor.js", budget: "budget.js", vaults: "vaults.js", status: "status.js"
|
|
10
13
|
};
|
|
11
14
|
const HELP = `Subly — x402 payments from Kamino USDC vault yield
|
|
12
15
|
|
|
13
|
-
Usage:
|
|
16
|
+
Usage: ${command} <command> [arguments]
|
|
14
17
|
doctor Check configuration, relayer and mainnet RPC (no signing)
|
|
15
18
|
vaults Print the locally trusted vault catalogue (offline)
|
|
16
19
|
setup-link [options] Create a human owner approval link
|
|
@@ -18,6 +21,7 @@ Usage: pay <command> [arguments]
|
|
|
18
21
|
deposit <rawUSDC> [approvalId] Deposit into the selected vault (real funds)
|
|
19
22
|
budget Refresh and read the selected vault's yield budget
|
|
20
23
|
withdraw <rawUSDC> [approvalId] Withdraw to the agent wallet (real funds)
|
|
24
|
+
status <dep_...|wdr_...> Check the original deposit/withdrawal (no new transaction)
|
|
21
25
|
fetch <URL> Pay a compatible x402 API within the configured cap
|
|
22
26
|
mcp Start the stdio MCP server
|
|
23
27
|
--version Print package version
|
|
@@ -26,17 +30,17 @@ Amounts: 1000000 raw USDC = 1 USDC. Requires Node.js 24+.
|
|
|
26
30
|
Set SUBLY_RELAYER_URL, SOLANA_RPC_URL and a wallet signer before use.
|
|
27
31
|
Local signer: SUBLY_DEMO_AGENT_KEYPAIR_PATH=/absolute/path/agent.json
|
|
28
32
|
Payment cap: SUBLY_MCP_MAX_AMOUNT_RAW_USDC (default 10000 = 0.01 USDC).
|
|
29
|
-
Run
|
|
33
|
+
Run ${command} setup-link --help for policy options.
|
|
30
34
|
Guide: https://github.com/SublyFi/subly-payment-protocol/tree/main/packages/pay
|
|
31
35
|
`;
|
|
32
36
|
const [sub, ...rest] = process.argv.slice(2);
|
|
33
37
|
if (sub === "--version" || sub === "-v") {
|
|
34
|
-
console.log(
|
|
38
|
+
console.log(version);
|
|
35
39
|
} else if (!sub || sub === "--help" || sub === "-h" || sub === "help" || rest.includes("--help") || rest.includes("-h")) {
|
|
36
40
|
console.log(HELP);
|
|
37
41
|
if (sub === "setup-link") console.log("Policy options: --initial-deposit <rawUSDC> --approval-threshold <rawUSDC> --per-payment-cap <rawUSDC> --daily-api-cap <rawUSDC> --daily-deposit-cap <rawUSDC> --ttl-days <days>");
|
|
38
42
|
} else if (!Object.hasOwn(TARGETS, sub)) {
|
|
39
|
-
console.error(`Unknown command: ${sub}\nRun
|
|
43
|
+
console.error(`Unknown command: ${sub}\nRun ${command} --help.`);
|
|
40
44
|
process.exitCode = 1;
|
|
41
45
|
} else {
|
|
42
46
|
process.argv = [process.argv[0], join(here, TARGETS[sub]), ...rest];
|