solverr-agent 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +208 -2
- package/dist/agent-wallet.d.ts +64 -0
- package/dist/agent-wallet.js +194 -0
- package/dist/assets.d.ts +13 -0
- package/dist/assets.js +12 -0
- package/dist/chains/base.d.ts +24 -0
- package/dist/chains/base.js +96 -0
- package/dist/chains/chain-wallet.d.ts +15 -0
- package/dist/chains/chain-wallet.js +1 -0
- package/dist/chains/near.d.ts +28 -0
- package/dist/chains/near.js +96 -0
- package/dist/challenge.d.ts +11 -0
- package/dist/challenge.js +75 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +43 -0
- package/dist/coin-selection.d.ts +29 -0
- package/dist/coin-selection.js +60 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.js +33 -0
- package/dist/env.d.ts +4 -0
- package/dist/env.js +49 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +17 -0
- package/dist/gateway.d.ts +43 -0
- package/dist/gateway.js +24 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/limits.d.ts +18 -0
- package/dist/limits.js +31 -0
- package/dist/mcp-server.d.ts +13 -0
- package/dist/mcp-server.js +153 -0
- package/dist/payer.d.ts +28 -0
- package/dist/payer.js +157 -0
- package/dist/spend-log.d.ts +38 -0
- package/dist/spend-log.js +162 -0
- package/dist/timeout.d.ts +7 -0
- package/dist/timeout.js +16 -0
- package/dist/url-guard.d.ts +7 -0
- package/dist/url-guard.js +92 -0
- package/package.json +63 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Solverr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,209 @@
|
|
|
1
|
-
#
|
|
1
|
+
# <img src="https://github.com/forcequitx/solverr/raw/main/app/icon.svg" alt="Solverr" width="30" height="30"> solverr-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**A wallet for AI agents that pays Solverr x402 paywalls from NEAR and Base, within the spending limits you set.**
|
|
4
|
+
|
|
5
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/lightbulb.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Overview
|
|
6
|
+
|
|
7
|
+
`solverr-agent` is a wallet for AI agents that pays [Solverr](https://www.npmjs.com/package/solverr-x402) x402 paywalls from NEAR and Base, always within the spending limits you set. It also runs as a local MCP server, so Claude Code, Cursor or any MCP client can pay for API calls.
|
|
8
|
+
|
|
9
|
+
- **Keys:** the agent keeps them in memory on your machine and uses them only to sign transfers -> they are never logged.
|
|
10
|
+
- **Limits:** the agent checks the per-payment limit, the rolling 24-hour limit and the allowed hosts before every quote and again before every transfer -> a payment outside them is refused.
|
|
11
|
+
- **Spend log:** the agent writes every payment to a local JSON spend log -> the daily limit holds across restarts and across agents that share the same log file.
|
|
12
|
+
|
|
13
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/package.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install solverr-agent
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The package needs Node 22.22.2 or later (required by `near-api-js` 7).
|
|
20
|
+
|
|
21
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/code-xml.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Use It in Code
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createAgentWallet } from "solverr-agent";
|
|
25
|
+
|
|
26
|
+
const agent = createAgentWallet({
|
|
27
|
+
near: { accountId: process.env.AGENT_NEAR_ACCOUNT!, privateKey: process.env.AGENT_NEAR_KEY! },
|
|
28
|
+
base: { privateKey: process.env.AGENT_BASE_KEY! },
|
|
29
|
+
limits: { perPaymentUsd: 5, dailyUsd: 20, allow: ["api.example.com"] },
|
|
30
|
+
trustedGateways: ["https://pay.example.com"],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const res = await agent.fetch("https://api.example.com/premium-data"); // pays a 402 automatically
|
|
34
|
+
console.log(res.status, await res.json());
|
|
35
|
+
|
|
36
|
+
console.log(await agent.balances());
|
|
37
|
+
console.log(await agent.spending());
|
|
38
|
+
console.log(agent.limits());
|
|
39
|
+
agent.setLimits({ dailyUsd: 10 });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Options
|
|
43
|
+
|
|
44
|
+
| Option | Default | Meaning |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `near` | none | `{ accountId, privateKey: "ed25519:…", rpcUrl? }` |
|
|
47
|
+
| `base` | none | `{ privateKey: "0x…", rpcUrl? }` |
|
|
48
|
+
| `limits` | required | `{ perPaymentUsd, dailyUsd, allow }`. `allow` takes hosts such as `api.example.com` or `*.example.com`; an empty list allows nothing |
|
|
49
|
+
| `spendLogPath` | `~/.solverr/agent-spend-log.json` | Local spend log |
|
|
50
|
+
| `assets` | USDC, USDT, wNEAR on NEAR; USDC, ETH on Base | Coins to check and pay with |
|
|
51
|
+
| `trustedGateways` | required | Solverr URLs the agent will pay through, such as `https://pay.example.com`. The gateway prices the invoice, so a paywall that names any other gateway is refused with `untrusted_gateway` |
|
|
52
|
+
| `paymentTimeoutMs` | 180000 | How long to wait for the invoice to be paid after sending |
|
|
53
|
+
| `requestTimeoutMs` | 15000 | Timeout of each RPC and gateway call |
|
|
54
|
+
| `resourceTimeoutMs` | 30000 | Timeout of each paid request, and of the first request when you pass no `signal`, including reading the body. Raise it for endpoints that take longer to answer or stream their body |
|
|
55
|
+
|
|
56
|
+
RPCs default to `https://free.rpc.fastnear.com` (NEAR) and `https://mainnet.base.org` (Base).
|
|
57
|
+
|
|
58
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/triangle-alert.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Errors and Retries
|
|
59
|
+
|
|
60
|
+
### Payment errors
|
|
61
|
+
|
|
62
|
+
`agent.fetch` throws a `PaymentError` when it refuses or cannot finish a payment. Check `error.code` (`host_not_allowed`, `untrusted_gateway`, `per_payment_limit`, `daily_limit`, `test_invoice`, `no_funds`, `quote_unavailable`, `quote_mismatch`, `payment_timeout`, `payment_unconfirmed`, `paid_request_failed`, …) and `error.fundsSent`. When `fundsSent` is `true`, `error.invoiceId` names the invoice the money went to: read the message before you request that URL again.
|
|
63
|
+
|
|
64
|
+
When the gateway or a wallet RPC fails before any transfer, `agent.fetch` throws that error (for example a `SolverrApiError`), and nothing was sent. A NEAR RPC error names only the RPC's origin, never its full URL, because that URL may carry an API key.
|
|
65
|
+
|
|
66
|
+
### Abort signal
|
|
67
|
+
|
|
68
|
+
A `signal` you pass in `init` covers the first request and the payment up to the transfer. Aborting before then throws the signal's reason, and nothing is sent. Once the transfer starts, the signal no longer applies: the agent always finishes the payment and the paid request, so a slow payment never costs you the response you paid for.
|
|
69
|
+
|
|
70
|
+
### Retries of the paid request
|
|
71
|
+
|
|
72
|
+
After paying, the agent sends the request with `PAYMENT-SIGNATURE`, always with the same payment and `resourceTimeoutMs` (30 seconds by default) per attempt. After a network error, 429 or 5xx, it tries again, up to 3 attempts in all. A `402` that follows an attempt with no answer also counts as a failure, because that earlier request may still be using the payment. It waits 1 second, then 2 seconds, between attempts. After a 429 or 503, it waits at least the `Retry-After` the site sends, up to 60 seconds. It stops early when the next wait would end more than `paymentTimeoutMs` after the first attempt. If it still fails, or the site answers `402` to the paid request, `agent.fetch` throws `paid_request_failed` with `fundsSent: true`.
|
|
73
|
+
|
|
74
|
+
### Kept payments
|
|
75
|
+
|
|
76
|
+
A site that answers the paid request with an error (400 or higher) does not use the payment, so the invoice can still be redeemed. The wallet keeps such a payment in memory for the same method, origin and path. It also keeps it after `paid_request_failed` from a 429, 5xx, network error, or a `402` that followed an attempt with no answer.
|
|
77
|
+
|
|
78
|
+
The next time that URL answers `402`, the wallet sends the kept payment again before it pays anything new. This costs nothing and adds nothing to the spend log. The wallet drops the kept payment once the site accepts it. It also drops it when the site answers it with `402`, and then pays the new invoice.
|
|
79
|
+
|
|
80
|
+
`agent.unusedInvoice(url, method?)` returns the invoice ID of a kept payment. Kept payments are lost when the process stops.
|
|
81
|
+
|
|
82
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/server.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> MCP Server
|
|
83
|
+
|
|
84
|
+
Start the MCP server:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
AGENT_BASE_KEY=0x… \
|
|
88
|
+
AGENT_LIMIT_PER_PAYMENT_USD=1 \
|
|
89
|
+
AGENT_LIMIT_DAILY_USD=5 \
|
|
90
|
+
AGENT_ALLOW_HOSTS=api.example.com \
|
|
91
|
+
AGENT_TRUSTED_GATEWAYS=https://pay.example.com \
|
|
92
|
+
npx solverr-agent mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Add it to Claude Code:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
claude mcp add solverr-agent \
|
|
99
|
+
-e AGENT_NEAR_ACCOUNT=agent.near -e AGENT_NEAR_KEY=ed25519:… \
|
|
100
|
+
-e AGENT_LIMIT_PER_PAYMENT_USD=1 -e AGENT_LIMIT_DAILY_USD=5 -e AGENT_ALLOW_HOSTS=api.example.com \
|
|
101
|
+
-e AGENT_TRUSTED_GATEWAYS=https://pay.example.com \
|
|
102
|
+
-- npx solverr-agent mcp
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Tools
|
|
106
|
+
|
|
107
|
+
| Tool | What it does |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `pay_url` | Fetches `url` on a host in the allow list, with optional `method` (`GET` by default, or `POST`, `PUT`, `PATCH`, `DELETE`), `headers` and `body`. It pays a 402 within the limits, then returns `status`, `content_type`, the receipt as `payment`, `body` and `body_truncated`. Redirects are returned with their `location`, not followed. When the site answers with an error and a paid invoice was not used, the result also has `invoice_id` and `payment_unused: true`. Calling `pay_url` again with the same method and URL sends that payment again |
|
|
110
|
+
| `get_balances` | Balances of the coins the wallet can pay with |
|
|
111
|
+
| `get_spending` | Limits, total of the last 24 hours, recent payments |
|
|
112
|
+
| `set_limits` | Lowers `per_payment_usd` or `daily_usd`, or narrows the `allow` host list. It cannot raise them above the values the server started with, so text on a paid page cannot talk the model into spending more |
|
|
113
|
+
|
|
114
|
+
### Environment variables
|
|
115
|
+
|
|
116
|
+
| Variable | Required | Meaning |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `AGENT_NEAR_ACCOUNT`, `AGENT_NEAR_KEY` | one wallet | NEAR account and `ed25519:` key |
|
|
119
|
+
| `AGENT_BASE_KEY` | one wallet | Base private key |
|
|
120
|
+
| `AGENT_LIMIT_PER_PAYMENT_USD` | yes | Largest single payment |
|
|
121
|
+
| `AGENT_LIMIT_DAILY_USD` | yes | Largest total over 24 hours |
|
|
122
|
+
| `AGENT_ALLOW_HOSTS` | yes | Comma-separated hosts that `pay_url` may request and pay |
|
|
123
|
+
| `AGENT_TRUSTED_GATEWAYS` | yes | Comma-separated Solverr URLs the agent may pay through |
|
|
124
|
+
| `AGENT_NEAR_RPC_URL`, `AGENT_BASE_RPC_URL` | no | Custom RPCs |
|
|
125
|
+
| `AGENT_SPEND_LOG` | no | Spend log path |
|
|
126
|
+
| `AGENT_PAYMENT_TIMEOUT_MS` | no | Wait for payment, in ms |
|
|
127
|
+
| `AGENT_RESOURCE_TIMEOUT_MS` | no | `resourceTimeoutMs`, in ms |
|
|
128
|
+
|
|
129
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/workflow.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> How a Payment Works
|
|
130
|
+
|
|
131
|
+
```mermaid
|
|
132
|
+
flowchart TD
|
|
133
|
+
A["Request url with the caller's method"] -->|not 402| Z[Return response]
|
|
134
|
+
A -->|402 without a readable solverr offer| Z
|
|
135
|
+
A -->|402 with solverr scheme| B{Host allowed? Trusted gateway? Live invoice?}
|
|
136
|
+
B -->|no| X[PaymentError, nothing sent]
|
|
137
|
+
B -->|yes| C[Price the invoice in USD from /tokens]
|
|
138
|
+
C --> D{Within per-payment and 24 h limits?}
|
|
139
|
+
D -->|no| X
|
|
140
|
+
D -->|yes| E[Reserve the amount in the spend log]
|
|
141
|
+
E --> F[Rank coins held: coin the business receives, same coin on another chain, stablecoins, others]
|
|
142
|
+
F --> G[POST quote with own refund address]
|
|
143
|
+
G -->|422| F
|
|
144
|
+
G --> H{Coin, refund address, deposit address, amount, deadline, limits all check out?}
|
|
145
|
+
H -->|no| X
|
|
146
|
+
H -->|yes| I[Send exact amount_in_base to deposit_address]
|
|
147
|
+
I --> J[POST deposit tx hash, poll status until paid]
|
|
148
|
+
J -->|paid| K[Send the same request with PAYMENT-SIGNATURE, up to 3 tries]
|
|
149
|
+
J -->|underpaid, refunded, failed, timeout, any other error| Y[PaymentError, fundsSent = true, invoiceId]
|
|
150
|
+
K -->|402, or still failing| Y
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
How each chain sends the transfer, and how payments take turns:
|
|
154
|
+
|
|
155
|
+
- **Base:** ERC-20 `transfer`, or a plain ETH transfer for native ETH, signed with viem.
|
|
156
|
+
- **NEAR:** NEP-141 `ft_transfer` with 1 yoctoNEAR. 1Click deposit addresses are usually fresh implicit accounts, so the agent checks `storage_balance_of` first. When the address is not registered with the token, the agent adds `storage_deposit` (the token's `storage_balance_bounds.min`) in the same transaction. Keep a little more than 0.01 NEAR available. The agent holds back 0.01 NEAR for gas, and it also needs the token's storage deposit when the deposit address is new.
|
|
157
|
+
- **One payment at a time:** payments in one process run one at a time. Every spend log write re-reads the file while it holds `agent-spend-log.json.lock`. The limit check and the reservation happen in the same locked step, so two calls or two processes cannot both pass the daily limit. Quotes and transfers run outside the lock.
|
|
158
|
+
|
|
159
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/circle-question-mark.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Points That Cause Confusion
|
|
160
|
+
|
|
161
|
+
- **Test invoices are never paid on-chain.** Only the Solverr demo store issues `mode: "test"` invoices, and they have `TEST-` deposit addresses. The agent refuses them with `test_invoice` before it asks for a quote, and it refuses any quote with a `TEST-` address.
|
|
162
|
+
- **Native NEAR is not used to pay.** NEAR Intents takes wNEAR (`wrap.near`). Hold wNEAR, USDC or USDT on NEAR.
|
|
163
|
+
- **Route minimums.** 1Click can set a temporary minimum on a route, which makes the gateway refuse a quote for that coin. The agent prefers the exact coin the business receives, then the same coin on other chains, then other stablecoins. It tries up to three coins and moves on to the next one when a route is refused.
|
|
164
|
+
- **The spend log counts money that may have left the wallet.** Entries that were `sent`, `paid` or `failed` count toward the daily limit. Only `aborted` entries (nothing sent) do not. A corrupt log stops payments instead of resetting the total.
|
|
165
|
+
- **A stuck lock file.** A process that stops while writing can leave `<spend log>.lock` behind. The agent removes a lock older than 30 seconds. A younger one makes a payment wait up to 10 seconds and then fail.
|
|
166
|
+
- **`pay_url` only reaches public addresses.** Besides the allow list, it resolves the host and refuses loopback, private, link-local (including cloud metadata such as `169.254.169.254`), multicast and reserved IPv4 and IPv6 addresses. To reach a local server, put its exact address in `AGENT_ALLOW_HOSTS` and use it in the URL: `127.0.0.1`, `[::1]` or `localhost`. The request then connects only to the addresses that passed this check, so a host whose DNS answer changes afterwards cannot send it to a private network.
|
|
167
|
+
- **Give MCP tool calls enough time.** One `pay_url` call can take about 5 minutes with the default settings: up to 60 seconds to send the transfer, up to 3 minutes (`AGENT_PAYMENT_TIMEOUT_MS`) for the invoice to be paid, then the paid request. Many MCP clients stop waiting for a tool after 60 seconds. The payment still completes, but the model never sees the result and may pay again. Raise your client's tool timeout to at least 5 minutes; in Claude Code, set `MCP_TOOL_TIMEOUT=300000`.
|
|
168
|
+
- **A timeout is not a refusal.** On `payment_timeout` the transfer was sent. Check the status URL in the message before paying again.
|
|
169
|
+
- **`fundsSent: true` means the money left the wallet.** Every error after the transfer, including a spend log or gateway failure, carries `fundsSent: true` and the `invoiceId`. The MCP server returns them as `funds_sent` and `invoice_id`. An error that is not a `PaymentError` comes back with the code `request_failed` and `funds_sent: false`: a gateway or RPC failure before any transfer, a `set_limits` value above the starting one, or a host outside the starting allow list. A spend log write that fails after the transfer does not stop the payment. The entry still counts toward the daily limit.
|
|
170
|
+
|
|
171
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/shield-check.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Tests
|
|
172
|
+
|
|
173
|
+
The tests in `tests/` run with Vitest and never send a transaction or reach a network. Chains, wallets, the gateway, DNS and `fetch` are mocked, and spend logs are written to a fresh folder under the OS temp folder. They import `solverr-x402` from its `dist`, so build x402 first.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
cd packages
|
|
177
|
+
npm ci # install the pinned dev dependencies of every package
|
|
178
|
+
npm run build -w x402 # the agent imports solverr-x402's build
|
|
179
|
+
cd agent
|
|
180
|
+
npx vitest run # run every test
|
|
181
|
+
npx tsc --noEmit -p tests/tsconfig.json # type-check the tests
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/rocket.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> Build and Publish
|
|
185
|
+
|
|
186
|
+
### Build
|
|
187
|
+
|
|
188
|
+
`solverr-agent` depends on `solverr-x402` at an exact version. Both are members of the npm workspace in `packages/` (`packages/package.json`, one `packages/package-lock.json`), so during development the dependency resolves to the local `packages/x402` folder. Build x402 first, because the agent compiles against its `dist`:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
cd packages
|
|
192
|
+
npm install
|
|
193
|
+
npm run build -w x402
|
|
194
|
+
npm run build -w agent
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Publish
|
|
198
|
+
|
|
199
|
+
Publish `solverr-x402` before `solverr-agent`: the agent's `package.json` names the x402 version it needs (`"solverr-x402": "0.1.0"`), and npm can only install the agent once that version exists on the registry. When x402 gets a new version, set the same exact version in the agent's `dependencies` and run `npm install` in `packages/` to update the lockfile.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
cd packages
|
|
203
|
+
npm publish -w x402
|
|
204
|
+
npm publish -w agent
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## <img src="https://github.com/forcequitx/solverr/raw/main/assets/icons/file-text.svg" width="24" height="24" style="vertical-align: middle; margin-right: 4px;"> License
|
|
208
|
+
|
|
209
|
+
Released under the **MIT License**.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Dispatcher, type RequestInit, type Response } from "undici";
|
|
2
|
+
import { type WalletAsset } from "./assets.js";
|
|
3
|
+
import { type SpendingLimits } from "./limits.js";
|
|
4
|
+
import { type SpendEntry } from "./spend-log.js";
|
|
5
|
+
export interface AgentWalletOptions {
|
|
6
|
+
near?: {
|
|
7
|
+
accountId: string;
|
|
8
|
+
privateKey: string;
|
|
9
|
+
rpcUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
base?: {
|
|
12
|
+
privateKey: string;
|
|
13
|
+
rpcUrl?: string;
|
|
14
|
+
};
|
|
15
|
+
limits: SpendingLimits;
|
|
16
|
+
/** Spend log file; defaults to `~/.solverr/agent-spend-log.json`. */
|
|
17
|
+
spendLogPath?: string;
|
|
18
|
+
/** Coins to check and pay with; defaults to USDC, USDT and wNEAR on NEAR, and USDC and ETH on Base. */
|
|
19
|
+
assets?: WalletAsset[];
|
|
20
|
+
/** Solverr gateway URLs the agent will pay through, such as `https://pay.example.com`. At least one is required. */
|
|
21
|
+
trustedGateways: string[];
|
|
22
|
+
/** How long to wait for an invoice to be paid after sending. Default 3 minutes. */
|
|
23
|
+
paymentTimeoutMs?: number;
|
|
24
|
+
/** Timeout of each RPC and gateway call. Default 15 seconds. */
|
|
25
|
+
requestTimeoutMs?: number;
|
|
26
|
+
/** Timeout of each paid request, and of the first request when no `signal` is passed, body included. Default 30 seconds; raise it for
|
|
27
|
+
endpoints that take longer to answer or stream their body. */
|
|
28
|
+
resourceTimeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface AssetBalance {
|
|
31
|
+
asset_id: string;
|
|
32
|
+
symbol: string;
|
|
33
|
+
blockchain: string;
|
|
34
|
+
address: string;
|
|
35
|
+
amount: string;
|
|
36
|
+
amount_base: string;
|
|
37
|
+
}
|
|
38
|
+
export interface SpendingSummary {
|
|
39
|
+
limits: SpendingLimits;
|
|
40
|
+
spent_last_24h_usd: number;
|
|
41
|
+
remaining_today_usd: number;
|
|
42
|
+
recent: SpendEntry[];
|
|
43
|
+
}
|
|
44
|
+
/** Init for undici's `fetch`: `dispatcher` may be an undici dispatcher pinned to approved addresses (Node's `fetch` refuses those). */
|
|
45
|
+
export type AgentFetchInit = RequestInit & {
|
|
46
|
+
dispatcher?: Dispatcher;
|
|
47
|
+
};
|
|
48
|
+
export interface AgentWallet {
|
|
49
|
+
/** Like `fetch`, but pays `solverr` x402 challenges within the limits and then sends the request again with the payment. */
|
|
50
|
+
fetch(input: string | URL, init?: AgentFetchInit): Promise<Response>;
|
|
51
|
+
balances(): Promise<{
|
|
52
|
+
balances: AssetBalance[];
|
|
53
|
+
errors: string[];
|
|
54
|
+
}>;
|
|
55
|
+
spending(): Promise<SpendingSummary>;
|
|
56
|
+
/** The current spending limits, read without the spend log. */
|
|
57
|
+
limits(): SpendingLimits;
|
|
58
|
+
setLimits(limits: Partial<SpendingLimits>): SpendingLimits;
|
|
59
|
+
/** The invoice of a payment kept for `method` and `url` because the site answered the paid request with an error;
|
|
60
|
+
the next 402 there sends that payment again before anything new is paid. */
|
|
61
|
+
unusedInvoice(url: string | URL, method?: string): string | undefined;
|
|
62
|
+
}
|
|
63
|
+
/** Creates an agent wallet. Keys stay in memory and are only used to sign transfers on this machine. */
|
|
64
|
+
export declare function createAgentWallet(options: AgentWalletOptions): AgentWallet;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, PAYMENT_SIGNATURE_HEADER, X402_VERSION, decodePaymentResponseHeader, encodePaymentSignatureHeader, readHeader, } from "solverr-x402";
|
|
2
|
+
import { fetch, Headers } from "undici";
|
|
3
|
+
import { formatUnits } from "viem";
|
|
4
|
+
import { DEFAULT_ASSETS } from "./assets.js";
|
|
5
|
+
import { BaseWallet } from "./chains/base.js";
|
|
6
|
+
import { NearWallet } from "./chains/near.js";
|
|
7
|
+
import { parseChallenge } from "./challenge.js";
|
|
8
|
+
import { DEFAULT_PAYMENT_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_RESOURCE_TIMEOUT_MS, PAID_REQUEST_ATTEMPTS, PAID_REQUEST_MAX_WAIT_MS, PAID_REQUEST_RETRY_MS, } from "./config.js";
|
|
9
|
+
import { PaymentError, errorSummary } from "./errors.js";
|
|
10
|
+
import { GatewayClient } from "./gateway.js";
|
|
11
|
+
import { isHostAllowed, normalizeLimits } from "./limits.js";
|
|
12
|
+
import { Payer } from "./payer.js";
|
|
13
|
+
import { SpendLog } from "./spend-log.js";
|
|
14
|
+
import { sleep } from "./timeout.js";
|
|
15
|
+
const RECENT_ENTRIES = 20;
|
|
16
|
+
/** Whether a request body can be sent again after paying; streams and (async) iterables are used up by the first request. */
|
|
17
|
+
function isReplayableBody(body) {
|
|
18
|
+
return (body === undefined ||
|
|
19
|
+
body === null ||
|
|
20
|
+
typeof body === "string" ||
|
|
21
|
+
body instanceof ArrayBuffer ||
|
|
22
|
+
ArrayBuffer.isView(body) ||
|
|
23
|
+
body instanceof Blob ||
|
|
24
|
+
body instanceof FormData ||
|
|
25
|
+
body instanceof URLSearchParams);
|
|
26
|
+
}
|
|
27
|
+
/** `Retry-After` in milliseconds, read as delay-seconds like `requestJson` in solverr-x402; 0 when it is missing or not a number. */
|
|
28
|
+
const retryAfterMs = (response) => Number(response.headers.get("Retry-After") ?? 0) * 1000 || 0;
|
|
29
|
+
/** The method, origin and path a kept payment belongs to; the paywall binds each invoice to the method and path it was issued for. */
|
|
30
|
+
const paymentKey = (method, url) => `${(method ?? "GET").toUpperCase()} ${url.origin}${url.pathname}`;
|
|
31
|
+
/** Sends a request carrying a paid invoice, retrying after a network error, 429, 5xx, or a 402 that follows an attempt with no answer (whose request
|
|
32
|
+
may still be using the payment); each attempt has `attemptTimeoutMs`. A 429 or 503 waits at least its `Retry-After`, capped at PAID_REQUEST_MAX_WAIT_MS,
|
|
33
|
+
and no wait ends later than `timeoutMs` after the first send. Returns any other response below 500; throws `PaymentError` with `fundsSent` at the end. */
|
|
34
|
+
async function fetchPaid(url, init, invoiceId, timeoutMs, attemptTimeoutMs) {
|
|
35
|
+
const deadline = Date.now() + timeoutMs;
|
|
36
|
+
let failure = "no response";
|
|
37
|
+
let retryAfter = 0;
|
|
38
|
+
let unanswered = false;
|
|
39
|
+
for (let attempt = 1; attempt <= PAID_REQUEST_ATTEMPTS; attempt++) {
|
|
40
|
+
if (attempt > 1) {
|
|
41
|
+
const delay = Math.min(Math.max(PAID_REQUEST_RETRY_MS * (attempt - 1), retryAfter), PAID_REQUEST_MAX_WAIT_MS);
|
|
42
|
+
if (Date.now() + delay > deadline)
|
|
43
|
+
break;
|
|
44
|
+
await sleep(delay);
|
|
45
|
+
}
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await fetch(url, { ...init, signal: AbortSignal.timeout(attemptTimeoutMs) });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
failure = errorSummary(error, 120);
|
|
52
|
+
retryAfter = 0;
|
|
53
|
+
unanswered = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (response.status === 402 && unanswered) {
|
|
57
|
+
failure = "HTTP 402 while an earlier attempt may still be using the payment";
|
|
58
|
+
retryAfter = 0;
|
|
59
|
+
await response.body?.cancel();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (response.status !== 429 && response.status < 500)
|
|
63
|
+
return response;
|
|
64
|
+
failure = `HTTP ${response.status}`;
|
|
65
|
+
retryAfter = response.status === 429 || response.status === 503 ? retryAfterMs(response) : 0;
|
|
66
|
+
await response.body?.cancel();
|
|
67
|
+
}
|
|
68
|
+
throw new PaymentError("paid_request_failed", `Invoice ${invoiceId} is paid, but the request to ${url.host} with the payment failed (${failure}). The wallet keeps this payment and sends it again on the next request to the same URL instead of paying again.`, true, invoiceId);
|
|
69
|
+
}
|
|
70
|
+
/** Creates an agent wallet. Keys stay in memory and are only used to sign transfers on this machine. */
|
|
71
|
+
export function createAgentWallet(options) {
|
|
72
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
73
|
+
const wallets = [];
|
|
74
|
+
if (options.near)
|
|
75
|
+
wallets.push(new NearWallet({ ...options.near, requestTimeoutMs }));
|
|
76
|
+
if (options.base)
|
|
77
|
+
wallets.push(new BaseWallet({ ...options.base, requestTimeoutMs }));
|
|
78
|
+
if (wallets.length === 0)
|
|
79
|
+
throw new Error("Configure at least one wallet: near or base.");
|
|
80
|
+
if (!options.trustedGateways?.length) {
|
|
81
|
+
throw new Error("Set trustedGateways to the Solverr URLs the agent may pay through, such as https://pay.example.com.");
|
|
82
|
+
}
|
|
83
|
+
const trustedGateways = options.trustedGateways.map((gateway) => new URL(gateway).origin);
|
|
84
|
+
const assets = (options.assets ?? DEFAULT_ASSETS).filter((asset) => wallets.some((wallet) => wallet.blockchain === asset.blockchain));
|
|
85
|
+
const spendLog = new SpendLog(options.spendLogPath);
|
|
86
|
+
let limits = normalizeLimits(options.limits);
|
|
87
|
+
const paymentTimeoutMs = options.paymentTimeoutMs ?? DEFAULT_PAYMENT_TIMEOUT_MS;
|
|
88
|
+
const resourceTimeoutMs = options.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
|
|
89
|
+
const payer = new Payer({ wallets, assets, spendLog, gateway: new GatewayClient(requestTimeoutMs), limits: () => limits, paymentTimeoutMs });
|
|
90
|
+
/** Paid invoices a site answered with an error, by `paymentKey`. The paywall releases such a payment, so it is sent again instead of paying anew;
|
|
91
|
+
it is dropped once the site accepts it (below 400) or refuses it with 402. Nothing is reserved or logged for a resend, since nothing is spent. */
|
|
92
|
+
const unusedPayments = new Map();
|
|
93
|
+
/** Sends the request with `payment` and returns the response, 402 included; an error of 400 or more other than 402, or no response at all,
|
|
94
|
+
keeps the payment under `key`. */
|
|
95
|
+
async function sendPayment(url, init, payment, key) {
|
|
96
|
+
const headers = new Headers(init.headers);
|
|
97
|
+
headers.set(PAYMENT_SIGNATURE_HEADER, payment.signature);
|
|
98
|
+
let response;
|
|
99
|
+
try {
|
|
100
|
+
response = await fetchPaid(url, { ...init, headers }, payment.invoiceId, paymentTimeoutMs, resourceTimeoutMs);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
unusedPayments.set(key, payment);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (response.status >= 400 && response.status !== 402)
|
|
107
|
+
unusedPayments.set(key, payment);
|
|
108
|
+
return response;
|
|
109
|
+
}
|
|
110
|
+
async function paidFetch(input, init = {}) {
|
|
111
|
+
const request = { ...init, signal: init.signal ?? AbortSignal.timeout(resourceTimeoutMs) };
|
|
112
|
+
const first = await fetch(input, request);
|
|
113
|
+
if (first.status !== 402)
|
|
114
|
+
return first;
|
|
115
|
+
let challenge;
|
|
116
|
+
try {
|
|
117
|
+
challenge = parseChallenge(first.headers.get(PAYMENT_REQUIRED_HEADER), trustedGateways);
|
|
118
|
+
if (challenge && !isReplayableBody(init.body))
|
|
119
|
+
throw new PaymentError("body_not_replayable", "A streamed request body cannot be sent again after paying; pass a string or buffer.");
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
await first.body?.cancel();
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (!challenge)
|
|
126
|
+
return first;
|
|
127
|
+
/** The URL that answered 402 (after redirects) is the one checked against the allow list and retried. */
|
|
128
|
+
const resourceUrl = new URL(first.url || input.toString());
|
|
129
|
+
await first.body?.cancel();
|
|
130
|
+
const key = paymentKey(init.method, resourceUrl);
|
|
131
|
+
const unused = unusedPayments.get(key);
|
|
132
|
+
/** Taken out while it is sent once, so concurrent calls never send the same kept payment together. */
|
|
133
|
+
if (unused && isHostAllowed(resourceUrl.hostname, limits.allow)) {
|
|
134
|
+
unusedPayments.delete(key);
|
|
135
|
+
const resent = await sendPayment(resourceUrl, init, unused, key);
|
|
136
|
+
if (resent.status !== 402)
|
|
137
|
+
return resent;
|
|
138
|
+
await resent.body?.cancel();
|
|
139
|
+
}
|
|
140
|
+
/** The caller's signal can stop the payment up to the transfer; after that the paid request is always completed. */
|
|
141
|
+
await payer.pay(resourceUrl, challenge, init.signal ?? undefined);
|
|
142
|
+
const invoiceId = challenge.extra.invoice_id;
|
|
143
|
+
const payment = {
|
|
144
|
+
x402Version: X402_VERSION,
|
|
145
|
+
resource: challenge.paymentRequired.resource,
|
|
146
|
+
accepted: challenge.requirement,
|
|
147
|
+
payload: { invoice_id: invoiceId },
|
|
148
|
+
};
|
|
149
|
+
const response = await sendPayment(resourceUrl, init, { invoiceId, signature: encodePaymentSignatureHeader(payment) }, key);
|
|
150
|
+
if (response.status !== 402)
|
|
151
|
+
return response;
|
|
152
|
+
const settlement = readHeader(response.headers.get(PAYMENT_RESPONSE_HEADER), decodePaymentResponseHeader);
|
|
153
|
+
await response.body?.cancel();
|
|
154
|
+
throw new PaymentError("paid_request_failed", `Invoice ${invoiceId} is paid, but ${resourceUrl.host} refused the payment: ${settlement?.errorMessage ?? "402 Payment Required"}. Do not pay for this request again; ask the site about invoice ${invoiceId}.`, true, invoiceId);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
fetch: paidFetch,
|
|
158
|
+
async balances() {
|
|
159
|
+
const errors = [];
|
|
160
|
+
const results = await Promise.all(assets.map(async (asset) => {
|
|
161
|
+
const wallet = wallets.find((item) => item.blockchain === asset.blockchain);
|
|
162
|
+
try {
|
|
163
|
+
const amount = await wallet.balance(asset);
|
|
164
|
+
return { asset_id: asset.asset_id, symbol: asset.symbol, blockchain: asset.blockchain, address: wallet.address, amount: formatUnits(amount, asset.decimals), amount_base: amount.toString() };
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
errors.push(`${asset.symbol} on ${asset.blockchain}: ${errorSummary(error, 120)}`);
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}));
|
|
171
|
+
return { balances: results.filter((item) => item !== undefined), errors };
|
|
172
|
+
},
|
|
173
|
+
async spending() {
|
|
174
|
+
const spent = await spendLog.spentLast24hUsd();
|
|
175
|
+
const entries = await spendLog.entries();
|
|
176
|
+
return {
|
|
177
|
+
limits,
|
|
178
|
+
spent_last_24h_usd: spent,
|
|
179
|
+
remaining_today_usd: Math.max(0, limits.dailyUsd - spent),
|
|
180
|
+
recent: entries.slice(-RECENT_ENTRIES).reverse(),
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
limits() {
|
|
184
|
+
return limits;
|
|
185
|
+
},
|
|
186
|
+
setLimits(changes) {
|
|
187
|
+
limits = normalizeLimits({ ...limits, ...changes });
|
|
188
|
+
return limits;
|
|
189
|
+
},
|
|
190
|
+
unusedInvoice(url, method) {
|
|
191
|
+
return unusedPayments.get(paymentKey(method, new URL(url)))?.invoiceId;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A coin the agent can hold and pay with, identified by its 1Click asset ID. */
|
|
2
|
+
export interface WalletAsset {
|
|
3
|
+
asset_id: string;
|
|
4
|
+
symbol: string;
|
|
5
|
+
/** 1Click blockchain code: `near` or `base`. */
|
|
6
|
+
blockchain: string;
|
|
7
|
+
decimals: number;
|
|
8
|
+
/** NEP-141 contract on NEAR, ERC-20 contract on Base, or `null` for native ETH. */
|
|
9
|
+
contract_address: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** Coins checked for balances and payments unless `assets` is set. */
|
|
12
|
+
export declare const DEFAULT_ASSETS: readonly WalletAsset[];
|
|
13
|
+
export declare function isUsdStablecoin(symbol: string): boolean;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Coins checked for balances and payments unless `assets` is set. */
|
|
2
|
+
export const DEFAULT_ASSETS = [
|
|
3
|
+
{ asset_id: "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", symbol: "USDC", blockchain: "near", decimals: 6, contract_address: "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" },
|
|
4
|
+
{ asset_id: "nep141:usdt.tether-token.near", symbol: "USDT", blockchain: "near", decimals: 6, contract_address: "usdt.tether-token.near" },
|
|
5
|
+
{ asset_id: "nep141:wrap.near", symbol: "wNEAR", blockchain: "near", decimals: 24, contract_address: "wrap.near" },
|
|
6
|
+
{ asset_id: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near", symbol: "USDC", blockchain: "base", decimals: 6, contract_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
|
|
7
|
+
{ asset_id: "nep141:base.omft.near", symbol: "ETH", blockchain: "base", decimals: 18, contract_address: null },
|
|
8
|
+
];
|
|
9
|
+
const USD_STABLECOINS = new Set(["USDC", "USDT", "DAI"]);
|
|
10
|
+
export function isUsdStablecoin(symbol) {
|
|
11
|
+
return USD_STABLECOINS.has(symbol.toUpperCase());
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { WalletAsset } from "../assets.js";
|
|
2
|
+
import type { ChainWallet, TransferResult } from "./chain-wallet.js";
|
|
3
|
+
interface BaseWalletOptions {
|
|
4
|
+
/** 32-byte hex private key, with or without `0x`. */
|
|
5
|
+
privateKey: string;
|
|
6
|
+
rpcUrl?: string;
|
|
7
|
+
requestTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Base wallet paying with ERC-20 `transfer` or native ETH. */
|
|
10
|
+
export declare class BaseWallet implements ChainWallet {
|
|
11
|
+
readonly blockchain = "base";
|
|
12
|
+
readonly address: string;
|
|
13
|
+
private readonly publicClient;
|
|
14
|
+
private readonly walletClient;
|
|
15
|
+
constructor(options: BaseWalletOptions);
|
|
16
|
+
isValidRecipient(address: string): boolean;
|
|
17
|
+
balance(asset: WalletAsset): Promise<bigint>;
|
|
18
|
+
transfer(asset: WalletAsset, to: string, amount: bigint): Promise<TransferResult>;
|
|
19
|
+
/** Throws when the node answered the broadcast with an error and does not hold `hash`, so nothing was sent; returns when the
|
|
20
|
+
transaction may still have been accepted (no answer, "already known", or a lookup that could not tell). */
|
|
21
|
+
private throwIfRefused;
|
|
22
|
+
private tokenOf;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { BaseError, InsufficientFundsError, RpcRequestError, TransactionNotFoundError, WaitForTransactionReceiptTimeoutError, createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, getAddress, http, isAddress, keccak256, zeroAddress, } from "viem";
|
|
2
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
3
|
+
import { base } from "viem/chains";
|
|
4
|
+
import { DEFAULT_BASE_RPC_URL, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_TRANSFER_TIMEOUT_MS } from "../config.js";
|
|
5
|
+
import { PaymentError, errorSummary } from "../errors.js";
|
|
6
|
+
const PRIVATE_KEY_PATTERN = /^(0x)?[0-9a-fA-F]{64}$/;
|
|
7
|
+
/** Node answers to a broadcast that mean the node already holds this exact transaction. */
|
|
8
|
+
const ALREADY_KNOWN = /already known|known transaction|already imported/i;
|
|
9
|
+
/** Base wallet paying with ERC-20 `transfer` or native ETH. */
|
|
10
|
+
export class BaseWallet {
|
|
11
|
+
blockchain = "base";
|
|
12
|
+
address;
|
|
13
|
+
publicClient;
|
|
14
|
+
walletClient;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
if (!PRIVATE_KEY_PATTERN.test(options.privateKey))
|
|
17
|
+
throw new Error("base.privateKey must be a 32-byte hex private key.");
|
|
18
|
+
const key = (options.privateKey.startsWith("0x") ? options.privateKey : `0x${options.privateKey}`);
|
|
19
|
+
const account = privateKeyToAccount(key);
|
|
20
|
+
const transport = http(options.rpcUrl ?? DEFAULT_BASE_RPC_URL, { timeout: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, retryCount: 2 });
|
|
21
|
+
this.address = account.address;
|
|
22
|
+
this.publicClient = createPublicClient({ chain: base, transport });
|
|
23
|
+
this.walletClient = createWalletClient({ account, chain: base, transport });
|
|
24
|
+
}
|
|
25
|
+
isValidRecipient(address) {
|
|
26
|
+
return isAddress(address, { strict: false }) && getAddress(address) !== zeroAddress;
|
|
27
|
+
}
|
|
28
|
+
async balance(asset) {
|
|
29
|
+
const token = this.tokenOf(asset);
|
|
30
|
+
if (!token)
|
|
31
|
+
return this.publicClient.getBalance({ address: this.address });
|
|
32
|
+
return this.publicClient.readContract({ address: token, abi: erc20Abi, functionName: "balanceOf", args: [this.address] });
|
|
33
|
+
}
|
|
34
|
+
async transfer(asset, to, amount) {
|
|
35
|
+
if (!this.isValidRecipient(to))
|
|
36
|
+
throw new PaymentError("quote_mismatch", `The deposit address ${to} is not a valid Base address.`);
|
|
37
|
+
const recipient = getAddress(to);
|
|
38
|
+
const token = this.tokenOf(asset);
|
|
39
|
+
const call = token
|
|
40
|
+
? { to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [recipient, amount] }) }
|
|
41
|
+
: { to: recipient, value: amount };
|
|
42
|
+
let serialized;
|
|
43
|
+
try {
|
|
44
|
+
const request = await this.walletClient.prepareTransactionRequest(call);
|
|
45
|
+
serialized = await this.walletClient.signTransaction(request);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new PaymentError("transfer_failed", `The Base transfer could not be prepared: ${errorSummary(error)}`);
|
|
49
|
+
}
|
|
50
|
+
const hash = keccak256(serialized);
|
|
51
|
+
try {
|
|
52
|
+
await this.publicClient.sendRawTransaction({ serializedTransaction: serialized });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
await this.throwIfRefused(error, hash);
|
|
56
|
+
/** Any other failure may have happened after the node accepted the transaction, so inclusion is checked below. */
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const receipt = await this.publicClient.waitForTransactionReceipt({ hash, timeout: DEFAULT_TRANSFER_TIMEOUT_MS });
|
|
60
|
+
if (receipt.status !== "success")
|
|
61
|
+
throw new PaymentError("transfer_failed", `The Base transfer ${hash} reverted.`, true);
|
|
62
|
+
return { hash };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof WaitForTransactionReceiptTimeoutError)
|
|
66
|
+
return { hash };
|
|
67
|
+
if (error instanceof PaymentError)
|
|
68
|
+
throw error;
|
|
69
|
+
throw new PaymentError("transfer_failed", `The Base transfer ${hash} could not be confirmed: ${errorSummary(error)}`, true);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Throws when the node answered the broadcast with an error and does not hold `hash`, so nothing was sent; returns when the
|
|
73
|
+
transaction may still have been accepted (no answer, "already known", or a lookup that could not tell). */
|
|
74
|
+
async throwIfRefused(error, hash) {
|
|
75
|
+
const answer = error instanceof BaseError ? error.walk((cause) => cause instanceof RpcRequestError) : null;
|
|
76
|
+
const nodeMessage = answer instanceof RpcRequestError ? answer.details : "";
|
|
77
|
+
if (!answer || ALREADY_KNOWN.test(nodeMessage))
|
|
78
|
+
return;
|
|
79
|
+
const mayHoldIt = await this.publicClient.getTransaction({ hash }).then(() => true, (lookup) => !(lookup instanceof TransactionNotFoundError));
|
|
80
|
+
if (mayHoldIt)
|
|
81
|
+
return;
|
|
82
|
+
if (InsufficientFundsError.nodeMessage.test(nodeMessage)) {
|
|
83
|
+
throw new PaymentError("no_funds", `${this.address} does not have enough ETH on Base for this transfer and its gas.`);
|
|
84
|
+
}
|
|
85
|
+
throw new PaymentError("transfer_failed", `The Base node refused the transfer: ${nodeMessage.slice(0, 300)}`);
|
|
86
|
+
}
|
|
87
|
+
tokenOf(asset) {
|
|
88
|
+
if (asset.blockchain !== "base")
|
|
89
|
+
throw new Error(`${asset.symbol} is not a Base asset.`);
|
|
90
|
+
if (asset.contract_address === null)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (!isAddress(asset.contract_address, { strict: false }))
|
|
93
|
+
throw new Error(`${asset.symbol} has an invalid Base contract address.`);
|
|
94
|
+
return getAddress(asset.contract_address);
|
|
95
|
+
}
|
|
96
|
+
}
|