@hugen/plugin-x402-solana 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 +117 -0
- package/dist/__tests__/security.test.d.ts +6 -0
- package/dist/__tests__/security.test.js +233 -0
- package/dist/actions/fetch-x402.d.ts +14 -0
- package/dist/actions/fetch-x402.js +235 -0
- package/dist/client.d.ts +28 -0
- package/dist/client.js +120 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +53 -0
- package/dist/security.d.ts +38 -0
- package/dist/security.js +163 -0
- package/dist/state.d.ts +15 -0
- package/dist/state.js +20 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hugen
|
|
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
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @hugen/plugin-x402-solana
|
|
2
|
+
|
|
3
|
+
Solana USDC payments for ElizaOS agents via the [x402 protocol](https://www.x402.org/).
|
|
4
|
+
|
|
5
|
+
**The first Solana x402 payment plugin for ElizaOS.** Enables agents to pay for any x402-protected API using Solana USDC — automatically handling 402 responses, signing SPL token transfers, and retrying with payment proof.
|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
|
|
9
|
+
The existing `@elizaos/plugin-x402` only supports EVM chains (Base, Ethereum). This plugin adds **Solana mainnet** support, giving agents access to the growing ecosystem of x402 APIs that accept Solana USDC.
|
|
10
|
+
|
|
11
|
+
- 75+ x402 API endpoints already accept Solana USDC payments
|
|
12
|
+
- Powered by `@x402/svm` (Coinbase official) and `@x402/fetch`
|
|
13
|
+
- Zero configuration beyond a Solana private key
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @hugen/plugin-x402-solana
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Agent Configuration
|
|
22
|
+
|
|
23
|
+
Add the plugin to your ElizaOS character config:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"plugins": ["@hugen/plugin-x402-solana"],
|
|
28
|
+
"settings": {
|
|
29
|
+
"secrets": {
|
|
30
|
+
"SOLANA_PRIVATE_KEY": "your-base58-encoded-solana-keypair"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The wallet needs USDC (SPL token) on Solana mainnet and a small amount of SOL for transaction fees.
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Agent says: "Search Hacker News for AI agent news"
|
|
42
|
+
↓
|
|
43
|
+
Plugin calls: GET https://scout.hugen.tokyo/scout/hn?q=AI+agents
|
|
44
|
+
↓
|
|
45
|
+
Server returns: 402 Payment Required (with Solana USDC payment option)
|
|
46
|
+
↓
|
|
47
|
+
Plugin signs: Solana USDC transfer ($0.005)
|
|
48
|
+
↓
|
|
49
|
+
Plugin retries: GET with X-PAYMENT header
|
|
50
|
+
↓
|
|
51
|
+
Server returns: 200 OK + search results
|
|
52
|
+
↓
|
|
53
|
+
Agent receives: Structured data to answer the user
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Available Action
|
|
57
|
+
|
|
58
|
+
### FETCH_X402_SOLANA
|
|
59
|
+
|
|
60
|
+
Fetches data from any x402-protected API with automatic Solana USDC payment.
|
|
61
|
+
|
|
62
|
+
**Parameters:**
|
|
63
|
+
| Name | Required | Description |
|
|
64
|
+
|------|----------|-------------|
|
|
65
|
+
| `url` | Yes | Full URL of the x402 API endpoint |
|
|
66
|
+
| `method` | No | HTTP method (GET or POST, default: GET) |
|
|
67
|
+
| `body` | No | JSON body for POST requests |
|
|
68
|
+
|
|
69
|
+
**Example URLs:**
|
|
70
|
+
- `https://scout.hugen.tokyo/scout/hn?q=AI+agents` — Search Hacker News ($0.005)
|
|
71
|
+
- `https://scout.hugen.tokyo/scout/report?q=MCP+servers` — 14-source intelligence report ($0.005)
|
|
72
|
+
- `https://defi.hugen.tokyo/defi/token?chain=1&address=0x...` — Token security audit ($0.005)
|
|
73
|
+
- `https://domain.hugen.tokyo/domain/full?domain=example.com` — Full domain intelligence ($0.01)
|
|
74
|
+
- `https://intel.hugen.tokyo/intel/token-report` — AI-synthesized token DD ($0.50)
|
|
75
|
+
|
|
76
|
+
## Security Configuration
|
|
77
|
+
|
|
78
|
+
The plugin includes built-in security controls configurable via plugin config:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"plugins": ["@hugen/plugin-x402-solana"],
|
|
83
|
+
"pluginConfig": {
|
|
84
|
+
"@hugen/plugin-x402-solana": {
|
|
85
|
+
"allowedDomains": "scout.hugen.tokyo,defi.hugen.tokyo",
|
|
86
|
+
"maxPaymentUsd": "1.00",
|
|
87
|
+
"allowAnyDomain": "false",
|
|
88
|
+
"fetchTimeoutMs": "30000"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Option | Default | Description |
|
|
95
|
+
|--------|---------|-------------|
|
|
96
|
+
| `allowedDomains` | `*.hugen.tokyo` (9 domains) | Comma-separated domain allowlist |
|
|
97
|
+
| `maxPaymentUsd` | `1.00` | Maximum USDC payment per request |
|
|
98
|
+
| `allowAnyDomain` | `false` | Set `true` to disable domain allowlist |
|
|
99
|
+
| `fetchTimeoutMs` | `30000` | Request timeout in milliseconds |
|
|
100
|
+
|
|
101
|
+
**Security features:**
|
|
102
|
+
- SSRF protection: blocks private IPs (IPv4 + IPv6 + IPv4-mapped IPv6)
|
|
103
|
+
- Domain allowlist: only approved domains by default
|
|
104
|
+
- Payment limit: rejects 402 requirements exceeding the USD threshold
|
|
105
|
+
- Request timeout: prevents hung requests from blocking the agent
|
|
106
|
+
|
|
107
|
+
## Technical Details
|
|
108
|
+
|
|
109
|
+
- Uses `@x402/svm` v2.5.0 (Coinbase official) for Solana transaction signing
|
|
110
|
+
- Uses `@x402/fetch` for automatic 402 → payment → retry flow
|
|
111
|
+
- Base58 + JSON array key format support (no external dependency)
|
|
112
|
+
- Requires Node.js 20+ (`@solana/kit` requirement)
|
|
113
|
+
- Compatible with ElizaOS 2.0.0-alpha.3+
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for security (URL validation, SSRF protection, IPv6) and base58 decoding.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js built-in test runner (node --test).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, beforeEach } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { validateUrl, maskQueryParams, configureSecurityPolicy, createMaxPaymentPolicy } from "../security.js";
|
|
9
|
+
import { decodeBase58, parsePrivateKey } from "../client.js";
|
|
10
|
+
// Minimal mock runtime for per-runtime config
|
|
11
|
+
const mockRuntime = {};
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
configureSecurityPolicy(mockRuntime, {});
|
|
14
|
+
});
|
|
15
|
+
// --- Base58 Decoding (#4 fix) ---
|
|
16
|
+
describe("decodeBase58", () => {
|
|
17
|
+
it("decodes '1' to exactly 1 zero byte", () => {
|
|
18
|
+
const result = decodeBase58("1");
|
|
19
|
+
assert.equal(result.length, 1);
|
|
20
|
+
assert.equal(result[0], 0);
|
|
21
|
+
});
|
|
22
|
+
it("decodes '11' to exactly 2 zero bytes", () => {
|
|
23
|
+
const result = decodeBase58("11");
|
|
24
|
+
assert.equal(result.length, 2);
|
|
25
|
+
assert.equal(result[0], 0);
|
|
26
|
+
assert.equal(result[1], 0);
|
|
27
|
+
});
|
|
28
|
+
it("decodes '2' to [1]", () => {
|
|
29
|
+
const result = decodeBase58("2");
|
|
30
|
+
assert.equal(result.length, 1);
|
|
31
|
+
assert.equal(result[0], 1);
|
|
32
|
+
});
|
|
33
|
+
it("decodes '1A' to [0, 9]", () => {
|
|
34
|
+
// '1' = leading zero, 'A' = index 9 in base58
|
|
35
|
+
const result = decodeBase58("1A");
|
|
36
|
+
assert.equal(result.length, 2);
|
|
37
|
+
assert.equal(result[0], 0);
|
|
38
|
+
assert.equal(result[1], 9);
|
|
39
|
+
});
|
|
40
|
+
it("throws on invalid base58 characters", () => {
|
|
41
|
+
assert.throws(() => decodeBase58("0OIl"), /Invalid base58 character/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
// --- parsePrivateKey ---
|
|
45
|
+
describe("parsePrivateKey", () => {
|
|
46
|
+
it("parses JSON array format", () => {
|
|
47
|
+
const jsonKey = JSON.stringify(Array.from({ length: 64 }, (_, i) => i));
|
|
48
|
+
const result = parsePrivateKey(jsonKey);
|
|
49
|
+
assert.equal(result.length, 64);
|
|
50
|
+
assert.equal(result[0], 0);
|
|
51
|
+
assert.equal(result[63], 63);
|
|
52
|
+
});
|
|
53
|
+
it("rejects invalid JSON array values", () => {
|
|
54
|
+
assert.throws(() => parsePrivateKey("[256, 0, 0]"), /numbers 0-255/);
|
|
55
|
+
});
|
|
56
|
+
it("rejects non-array JSON (object with { triggers base58 error)", () => {
|
|
57
|
+
assert.throws(() => parsePrivateKey('{"key": "value"}'), /Invalid base58 character/);
|
|
58
|
+
});
|
|
59
|
+
it("trims whitespace before parsing", () => {
|
|
60
|
+
const jsonKey = " " + JSON.stringify(Array.from({ length: 64 }, () => 42)) + " ";
|
|
61
|
+
const result = parsePrivateKey(jsonKey);
|
|
62
|
+
assert.equal(result.length, 64);
|
|
63
|
+
});
|
|
64
|
+
it("falls back to base58 for non-JSON input", () => {
|
|
65
|
+
const result = parsePrivateKey("2");
|
|
66
|
+
assert.ok(result.length > 0);
|
|
67
|
+
});
|
|
68
|
+
it("parses 64-char hex string as 32-byte private key", () => {
|
|
69
|
+
const hex = "ab".repeat(32); // 64 hex chars = 32 bytes
|
|
70
|
+
const result = parsePrivateKey(hex);
|
|
71
|
+
assert.equal(result.length, 32);
|
|
72
|
+
assert.equal(result[0], 0xab);
|
|
73
|
+
});
|
|
74
|
+
it("parses 128-char hex string as 64-byte keypair", () => {
|
|
75
|
+
const hex = "cd".repeat(64); // 128 hex chars = 64 bytes
|
|
76
|
+
const result = parsePrivateKey(hex);
|
|
77
|
+
assert.equal(result.length, 64);
|
|
78
|
+
assert.equal(result[0], 0xcd);
|
|
79
|
+
});
|
|
80
|
+
it("parses 0x-prefixed hex string", () => {
|
|
81
|
+
const hex = "0x" + "ef".repeat(32);
|
|
82
|
+
const result = parsePrivateKey(hex);
|
|
83
|
+
assert.equal(result.length, 32);
|
|
84
|
+
assert.equal(result[0], 0xef);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
// --- URL Validation ---
|
|
88
|
+
describe("validateUrl", () => {
|
|
89
|
+
it("allows known hugen.tokyo domains", () => {
|
|
90
|
+
configureSecurityPolicy(mockRuntime, {});
|
|
91
|
+
assert.doesNotThrow(() => validateUrl("https://scout.hugen.tokyo/scout/hn?q=test", mockRuntime));
|
|
92
|
+
assert.doesNotThrow(() => validateUrl("https://defi.hugen.tokyo/defi/token?chain=1", mockRuntime));
|
|
93
|
+
assert.doesNotThrow(() => validateUrl("https://intel.hugen.tokyo/intel/token-report", mockRuntime));
|
|
94
|
+
});
|
|
95
|
+
it("blocks unknown domains by default", () => {
|
|
96
|
+
configureSecurityPolicy(mockRuntime, {});
|
|
97
|
+
assert.throws(() => validateUrl("https://evil.com/steal", mockRuntime), /not in allowlist/);
|
|
98
|
+
});
|
|
99
|
+
it("blocks HTTP (non-HTTPS)", () => {
|
|
100
|
+
configureSecurityPolicy(mockRuntime, {});
|
|
101
|
+
assert.throws(() => validateUrl("http://scout.hugen.tokyo/scout/hn", mockRuntime), /Only HTTPS/);
|
|
102
|
+
});
|
|
103
|
+
it("blocks private IPv4 addresses", () => {
|
|
104
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
105
|
+
assert.throws(() => validateUrl("https://127.0.0.1/admin", mockRuntime), /private.*internal/i);
|
|
106
|
+
assert.throws(() => validateUrl("https://10.0.0.1/secret", mockRuntime), /private.*internal/i);
|
|
107
|
+
assert.throws(() => validateUrl("https://192.168.1.1/api", mockRuntime), /private.*internal/i);
|
|
108
|
+
assert.throws(() => validateUrl("https://172.16.0.1/api", mockRuntime), /private.*internal/i);
|
|
109
|
+
});
|
|
110
|
+
it("blocks localhost", () => {
|
|
111
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
112
|
+
assert.throws(() => validateUrl("https://localhost/api", mockRuntime), /private.*internal/i);
|
|
113
|
+
});
|
|
114
|
+
// #1 fix: IPv6 SSRF
|
|
115
|
+
it("blocks IPv6 loopback [::1]", () => {
|
|
116
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
117
|
+
assert.throws(() => validateUrl("https://[::1]/admin", mockRuntime), /private.*internal/i);
|
|
118
|
+
});
|
|
119
|
+
it("blocks IPv6 link-local [fe80::]", () => {
|
|
120
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
121
|
+
assert.throws(() => validateUrl("https://[fe80::1]/api", mockRuntime), /private.*internal/i);
|
|
122
|
+
});
|
|
123
|
+
it("blocks IPv6 ULA [fc00::]", () => {
|
|
124
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
125
|
+
assert.throws(() => validateUrl("https://[fc00::1]/api", mockRuntime), /private.*internal/i);
|
|
126
|
+
});
|
|
127
|
+
it("blocks IPv4-mapped IPv6 [::ffff:169.254.169.254] (cloud metadata)", () => {
|
|
128
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
129
|
+
assert.throws(() => validateUrl("https://[::ffff:169.254.169.254]/metadata", mockRuntime), /private.*internal/i);
|
|
130
|
+
});
|
|
131
|
+
it("blocks IPv4-mapped IPv6 [::ffff:127.0.0.1]", () => {
|
|
132
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
133
|
+
assert.throws(() => validateUrl("https://[::ffff:127.0.0.1]/admin", mockRuntime), /private.*internal/i);
|
|
134
|
+
});
|
|
135
|
+
it("blocks IPv4-mapped IPv6 [::ffff:10.0.0.1]", () => {
|
|
136
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
137
|
+
assert.throws(() => validateUrl("https://[::ffff:10.0.0.1]/secret", mockRuntime), /private.*internal/i);
|
|
138
|
+
});
|
|
139
|
+
it("allows custom domain allowlist", () => {
|
|
140
|
+
configureSecurityPolicy(mockRuntime, { allowedDomains: ["api.example.com"] });
|
|
141
|
+
assert.doesNotThrow(() => validateUrl("https://api.example.com/data", mockRuntime));
|
|
142
|
+
assert.throws(() => validateUrl("https://scout.hugen.tokyo/scout/hn", mockRuntime), /not in allowlist/);
|
|
143
|
+
});
|
|
144
|
+
it("allows any domain when configured", () => {
|
|
145
|
+
configureSecurityPolicy(mockRuntime, { allowAnyDomain: true });
|
|
146
|
+
assert.doesNotThrow(() => validateUrl("https://any-public-api.com/endpoint", mockRuntime));
|
|
147
|
+
});
|
|
148
|
+
it("rejects invalid URLs", () => {
|
|
149
|
+
configureSecurityPolicy(mockRuntime, {});
|
|
150
|
+
assert.throws(() => validateUrl("not-a-url", mockRuntime), /Invalid URL/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
// --- Payment Policy (#2 fix) ---
|
|
154
|
+
describe("createMaxPaymentPolicy", () => {
|
|
155
|
+
it("allows payments under the limit", () => {
|
|
156
|
+
const policy = createMaxPaymentPolicy(1.0); // $1.00
|
|
157
|
+
const requirements = [{ amount: "500000", scheme: "exact", network: "solana:mainnet" }]; // $0.50
|
|
158
|
+
const filtered = policy(2, requirements);
|
|
159
|
+
assert.equal(filtered.length, 1);
|
|
160
|
+
});
|
|
161
|
+
it("rejects payments over the limit", () => {
|
|
162
|
+
const policy = createMaxPaymentPolicy(1.0); // $1.00
|
|
163
|
+
const requirements = [{ amount: "2000000", scheme: "exact", network: "solana:mainnet" }]; // $2.00
|
|
164
|
+
const filtered = policy(2, requirements);
|
|
165
|
+
assert.equal(filtered.length, 0);
|
|
166
|
+
});
|
|
167
|
+
it("allows exactly the limit", () => {
|
|
168
|
+
const policy = createMaxPaymentPolicy(0.50); // $0.50
|
|
169
|
+
const requirements = [{ amount: "500000", scheme: "exact", network: "solana:mainnet" }]; // $0.50
|
|
170
|
+
const filtered = policy(2, requirements);
|
|
171
|
+
assert.equal(filtered.length, 1);
|
|
172
|
+
});
|
|
173
|
+
it("handles invalid amount strings", () => {
|
|
174
|
+
const policy = createMaxPaymentPolicy(1.0);
|
|
175
|
+
const requirements = [{ amount: "not-a-number", scheme: "exact" }];
|
|
176
|
+
const filtered = policy(2, requirements);
|
|
177
|
+
assert.equal(filtered.length, 0);
|
|
178
|
+
});
|
|
179
|
+
// H1 fix: Math.round prevents floating point truncation
|
|
180
|
+
it("allows $0.005 payment with maxPaymentUsd=0.005 (floating point safe)", () => {
|
|
181
|
+
const policy = createMaxPaymentPolicy(0.005);
|
|
182
|
+
const requirements = [{ amount: "5000", scheme: "exact", network: "solana:mainnet" }];
|
|
183
|
+
const filtered = policy(2, requirements);
|
|
184
|
+
assert.equal(filtered.length, 1, "Math.round should produce 5000, not 4999");
|
|
185
|
+
});
|
|
186
|
+
// R1-1 fix: Only USDC allowed
|
|
187
|
+
it("rejects non-USDC token payments", () => {
|
|
188
|
+
const policy = createMaxPaymentPolicy(1.0);
|
|
189
|
+
const requirements = [{
|
|
190
|
+
amount: "100",
|
|
191
|
+
scheme: "exact",
|
|
192
|
+
network: "solana:mainnet",
|
|
193
|
+
asset: "SoMeRaNdOmToKeNmInTaDdReSs111111111111111",
|
|
194
|
+
}];
|
|
195
|
+
const filtered = policy(2, requirements);
|
|
196
|
+
assert.equal(filtered.length, 0, "Non-USDC asset should be rejected");
|
|
197
|
+
});
|
|
198
|
+
it("allows USDC mainnet mint", () => {
|
|
199
|
+
const policy = createMaxPaymentPolicy(1.0);
|
|
200
|
+
const requirements = [{
|
|
201
|
+
amount: "5000",
|
|
202
|
+
scheme: "exact",
|
|
203
|
+
network: "solana:mainnet",
|
|
204
|
+
asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
205
|
+
}];
|
|
206
|
+
const filtered = policy(2, requirements);
|
|
207
|
+
assert.equal(filtered.length, 1, "USDC mainnet mint should be allowed");
|
|
208
|
+
});
|
|
209
|
+
it("allows payment when asset is not specified (backwards compat)", () => {
|
|
210
|
+
const policy = createMaxPaymentPolicy(1.0);
|
|
211
|
+
const requirements = [{ amount: "5000", scheme: "exact", network: "solana:mainnet" }];
|
|
212
|
+
const filtered = policy(2, requirements);
|
|
213
|
+
assert.equal(filtered.length, 1, "Missing asset should be allowed (backwards compat)");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
// --- Query Masking ---
|
|
217
|
+
describe("maskQueryParams", () => {
|
|
218
|
+
it("masks query parameter values", () => {
|
|
219
|
+
const masked = maskQueryParams("https://scout.hugen.tokyo/scout/hn?q=secret&per_page=5");
|
|
220
|
+
assert.ok(masked.includes("q=***"));
|
|
221
|
+
assert.ok(masked.includes("per_page=***"));
|
|
222
|
+
assert.ok(!masked.includes("secret"));
|
|
223
|
+
assert.ok(!masked.includes("=5"));
|
|
224
|
+
});
|
|
225
|
+
it("preserves URL without query params", () => {
|
|
226
|
+
const masked = maskQueryParams("https://scout.hugen.tokyo/health");
|
|
227
|
+
assert.equal(masked, "https://scout.hugen.tokyo/health");
|
|
228
|
+
});
|
|
229
|
+
it("handles malformed URLs gracefully", () => {
|
|
230
|
+
const masked = maskQueryParams("not-a-url?key=val");
|
|
231
|
+
assert.ok(!masked.includes("val"));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action: Fetch an x402-protected API with automatic Solana USDC payment.
|
|
3
|
+
*
|
|
4
|
+
* When the agent needs data from a paid API (scout intelligence, DeFi data,
|
|
5
|
+
* domain analysis, etc.), this action handles the x402 payment flow:
|
|
6
|
+
* 1. Validates URL against domain allowlist and SSRF rules
|
|
7
|
+
* 2. Makes the HTTP request (with timeout, no redirects)
|
|
8
|
+
* 3. Receives 402 Payment Required
|
|
9
|
+
* 4. Signs a Solana USDC transfer (enforced by payment policy: USDC only + max limit)
|
|
10
|
+
* 5. Retries with payment proof
|
|
11
|
+
* 6. Returns the API response to the agent
|
|
12
|
+
*/
|
|
13
|
+
import type { Action } from "@elizaos/core";
|
|
14
|
+
export declare const fetchX402Action: Action;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action: Fetch an x402-protected API with automatic Solana USDC payment.
|
|
3
|
+
*
|
|
4
|
+
* When the agent needs data from a paid API (scout intelligence, DeFi data,
|
|
5
|
+
* domain analysis, etc.), this action handles the x402 payment flow:
|
|
6
|
+
* 1. Validates URL against domain allowlist and SSRF rules
|
|
7
|
+
* 2. Makes the HTTP request (with timeout, no redirects)
|
|
8
|
+
* 3. Receives 402 Payment Required
|
|
9
|
+
* 4. Signs a Solana USDC transfer (enforced by payment policy: USDC only + max limit)
|
|
10
|
+
* 5. Retries with payment proof
|
|
11
|
+
* 6. Returns the API response to the agent
|
|
12
|
+
*/
|
|
13
|
+
import { getX402Fetch } from "../state.js";
|
|
14
|
+
import { validateUrl, maskQueryParams, getFetchTimeoutMs } from "../security.js";
|
|
15
|
+
/** Max response body size (4 MB). */
|
|
16
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* Read response body with streaming size enforcement.
|
|
19
|
+
* Prevents OOM from servers that lie about Content-Length or omit it.
|
|
20
|
+
*/
|
|
21
|
+
async function readBodySafe(response, maxBytes) {
|
|
22
|
+
const reader = response.body?.getReader();
|
|
23
|
+
if (!reader) {
|
|
24
|
+
// Fallback for environments without ReadableStream
|
|
25
|
+
const text = await response.text();
|
|
26
|
+
if (text.length > maxBytes) {
|
|
27
|
+
throw new Error(`Response body exceeds ${maxBytes} bytes`);
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let totalBytes = 0;
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
try {
|
|
35
|
+
while (true) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
if (done)
|
|
38
|
+
break;
|
|
39
|
+
totalBytes += value.length;
|
|
40
|
+
if (totalBytes > maxBytes) {
|
|
41
|
+
reader.cancel();
|
|
42
|
+
throw new Error(`Response body exceeds ${maxBytes} bytes`);
|
|
43
|
+
}
|
|
44
|
+
chunks.push(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
reader.releaseLock();
|
|
49
|
+
}
|
|
50
|
+
// Decode all chunks
|
|
51
|
+
let text = "";
|
|
52
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
53
|
+
text += decoder.decode(chunks[i], { stream: i < chunks.length - 1 });
|
|
54
|
+
}
|
|
55
|
+
text += decoder.decode();
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
export const fetchX402Action = {
|
|
59
|
+
name: "FETCH_X402_SOLANA",
|
|
60
|
+
description: "Fetch data from an x402-protected API, paying with Solana USDC. " +
|
|
61
|
+
"Use this when you need to access paid intelligence APIs like scout " +
|
|
62
|
+
"(multi-source search), DeFi security data, domain intelligence, " +
|
|
63
|
+
"weather data, email validation, or any x402-enabled service. " +
|
|
64
|
+
"Payment is automatic — the agent's Solana wallet pays USDC directly.",
|
|
65
|
+
similes: [
|
|
66
|
+
"pay for API with Solana",
|
|
67
|
+
"x402 Solana payment",
|
|
68
|
+
"fetch paid API",
|
|
69
|
+
"search with scout",
|
|
70
|
+
"get intelligence report",
|
|
71
|
+
"access paid endpoint",
|
|
72
|
+
],
|
|
73
|
+
parameters: [
|
|
74
|
+
{
|
|
75
|
+
name: "url",
|
|
76
|
+
description: "Full URL of the x402-protected API endpoint. " +
|
|
77
|
+
"Examples: https://scout.hugen.tokyo/scout/hn?q=AI+agents, " +
|
|
78
|
+
"https://defi.hugen.tokyo/defi/token?chain=1&address=0x...",
|
|
79
|
+
required: true,
|
|
80
|
+
schema: { type: "string" },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "method",
|
|
84
|
+
description: "HTTP method. Defaults to GET.",
|
|
85
|
+
required: false,
|
|
86
|
+
schema: { type: "string", enumValues: ["GET", "POST", "PUT"] },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "body",
|
|
90
|
+
description: "JSON request body for POST/PUT requests.",
|
|
91
|
+
required: false,
|
|
92
|
+
schema: { type: "string" },
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
validate: async (runtime) => {
|
|
96
|
+
return getX402Fetch(runtime) !== null;
|
|
97
|
+
},
|
|
98
|
+
handler: async (runtime, _message, _state, options, callback) => {
|
|
99
|
+
const x402Fetch = getX402Fetch(runtime);
|
|
100
|
+
if (!x402Fetch) {
|
|
101
|
+
if (callback) {
|
|
102
|
+
await callback({
|
|
103
|
+
text: "Solana x402 payment is not configured. Set SOLANA_PRIVATE_KEY in agent settings.",
|
|
104
|
+
actions: [],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { success: false, error: "x402-solana not initialized" };
|
|
108
|
+
}
|
|
109
|
+
// Extract parameters from HandlerOptions.parameters
|
|
110
|
+
const params = options?.parameters ??
|
|
111
|
+
options;
|
|
112
|
+
const url = params?.url;
|
|
113
|
+
if (!url) {
|
|
114
|
+
if (callback) {
|
|
115
|
+
await callback({ text: "No URL provided for the API request.", actions: [] });
|
|
116
|
+
}
|
|
117
|
+
return { success: false, error: "Missing url parameter" };
|
|
118
|
+
}
|
|
119
|
+
// Validate URL against allowlist and SSRF rules
|
|
120
|
+
try {
|
|
121
|
+
validateUrl(url, runtime);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
125
|
+
if (callback) {
|
|
126
|
+
await callback({ text: `URL blocked: ${reason}`, actions: [] });
|
|
127
|
+
}
|
|
128
|
+
return { success: false, error: reason };
|
|
129
|
+
}
|
|
130
|
+
const method = (params?.method || "GET").toUpperCase();
|
|
131
|
+
const body = params?.body;
|
|
132
|
+
const safeUrl = maskQueryParams(url);
|
|
133
|
+
// Timeout via AbortController
|
|
134
|
+
const timeoutMs = getFetchTimeoutMs(runtime);
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
137
|
+
try {
|
|
138
|
+
console.error(`[x402-solana] ${method} ${safeUrl}`);
|
|
139
|
+
const init = {
|
|
140
|
+
method,
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
// R1-2 fix: Block redirects to prevent allowlist bypass via 302/307
|
|
143
|
+
redirect: "error",
|
|
144
|
+
};
|
|
145
|
+
if (body && (method === "POST" || method === "PUT")) {
|
|
146
|
+
init.body = body;
|
|
147
|
+
init.headers = { "Content-Type": "application/json" };
|
|
148
|
+
}
|
|
149
|
+
const response = await x402Fetch(url, init);
|
|
150
|
+
// R2-① fix: Streaming body reader with actual byte limit (not trusting Content-Length)
|
|
151
|
+
const text = await readBodySafe(response, MAX_RESPONSE_BYTES);
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
console.error(`[x402-solana] ${safeUrl} → ${response.status}`);
|
|
154
|
+
if (callback) {
|
|
155
|
+
await callback({
|
|
156
|
+
text: `API returned status ${response.status}: ${text.slice(0, 500)}`,
|
|
157
|
+
actions: [],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return { success: false, error: `HTTP ${response.status}` };
|
|
161
|
+
}
|
|
162
|
+
// Parse as JSON — keep full structure
|
|
163
|
+
let data;
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(text);
|
|
166
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
167
|
+
data = parsed;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
data = { result: parsed };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
data = { text: text.slice(0, 4000) };
|
|
175
|
+
}
|
|
176
|
+
console.error(`[x402-solana] OK ${safeUrl} (${text.length}B)`);
|
|
177
|
+
if (callback) {
|
|
178
|
+
const summary = JSON.stringify(data, null, 2).slice(0, 2000);
|
|
179
|
+
await callback({ text: summary, actions: [] });
|
|
180
|
+
}
|
|
181
|
+
return { success: true, data: data };
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
185
|
+
const isTimeout = err instanceof DOMException && err.name === "AbortError";
|
|
186
|
+
const isRedirect = message.includes("redirect");
|
|
187
|
+
let displayMsg;
|
|
188
|
+
if (isTimeout) {
|
|
189
|
+
displayMsg = `Request timed out after ${timeoutMs}ms`;
|
|
190
|
+
}
|
|
191
|
+
else if (isRedirect) {
|
|
192
|
+
displayMsg = `Request was redirected (blocked for security): ${safeUrl}`;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
displayMsg = message;
|
|
196
|
+
}
|
|
197
|
+
console.error(`[x402-solana] Error on ${safeUrl}: ${displayMsg}`);
|
|
198
|
+
if (callback) {
|
|
199
|
+
await callback({ text: `Failed to fetch: ${displayMsg}`, actions: [] });
|
|
200
|
+
}
|
|
201
|
+
return { success: false, error: displayMsg };
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
examples: [
|
|
208
|
+
[
|
|
209
|
+
{
|
|
210
|
+
name: "user",
|
|
211
|
+
content: { text: "Search Hacker News for AI agent news" },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "assistant",
|
|
215
|
+
content: {
|
|
216
|
+
text: "I'll search Hacker News for AI agent news using the Scout API.",
|
|
217
|
+
actions: ["FETCH_X402_SOLANA"],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
[
|
|
222
|
+
{
|
|
223
|
+
name: "user",
|
|
224
|
+
content: { text: "Get a multi-source intelligence report on MCP servers" },
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "assistant",
|
|
228
|
+
content: {
|
|
229
|
+
text: "I'll generate a multi-source intelligence report on MCP servers.",
|
|
230
|
+
actions: ["FETCH_X402_SOLANA"],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
],
|
|
235
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solana x402 client setup.
|
|
3
|
+
*
|
|
4
|
+
* Creates a payment-aware fetch that automatically handles 402 responses
|
|
5
|
+
* by signing Solana USDC transfers via the x402 protocol.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Decode a base58-encoded Solana keypair to Uint8Array.
|
|
9
|
+
* Solana CLI keypairs are 64 bytes: [32-byte private key | 32-byte public key].
|
|
10
|
+
*/
|
|
11
|
+
export declare function decodeBase58(encoded: string): Uint8Array;
|
|
12
|
+
/**
|
|
13
|
+
* Parse a Solana private key from multiple formats.
|
|
14
|
+
*
|
|
15
|
+
* Supports:
|
|
16
|
+
* - Hex string (64 chars = 32 bytes private key, 128 chars = 64 bytes keypair)
|
|
17
|
+
* - Base58 string: standard Solana wallet format (64-byte keypair)
|
|
18
|
+
* - JSON array: Solana CLI's ~/.config/solana/id.json format ([12, 34, ...])
|
|
19
|
+
*/
|
|
20
|
+
export declare function parsePrivateKey(input: string): Uint8Array;
|
|
21
|
+
/**
|
|
22
|
+
* Create a payment-aware fetch function for Solana x402 payments.
|
|
23
|
+
*
|
|
24
|
+
* @param privateKeyInput - Base58-encoded Solana keypair or JSON array
|
|
25
|
+
* @param maxPaymentUsd - Maximum payment amount in USD per request
|
|
26
|
+
* @returns A fetch function that auto-handles 402 → Solana USDC payment → retry
|
|
27
|
+
*/
|
|
28
|
+
export declare function createSolanaX402Fetch(privateKeyInput: string, maxPaymentUsd?: number): Promise<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solana x402 client setup.
|
|
3
|
+
*
|
|
4
|
+
* Creates a payment-aware fetch that automatically handles 402 responses
|
|
5
|
+
* by signing Solana USDC transfers via the x402 protocol.
|
|
6
|
+
*/
|
|
7
|
+
import { x402Client } from "@x402/fetch";
|
|
8
|
+
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
9
|
+
import { toClientSvmSigner } from "@x402/svm";
|
|
10
|
+
import { registerExactSvmScheme } from "@x402/svm/exact/client";
|
|
11
|
+
import { createKeyPairSignerFromBytes, createKeyPairSignerFromPrivateKeyBytes } from "@solana/signers";
|
|
12
|
+
import { createMaxPaymentPolicy } from "./security.js";
|
|
13
|
+
/** Solana mainnet CAIP-2 identifier */
|
|
14
|
+
const SOLANA_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
15
|
+
/**
|
|
16
|
+
* Decode a base58-encoded Solana keypair to Uint8Array.
|
|
17
|
+
* Solana CLI keypairs are 64 bytes: [32-byte private key | 32-byte public key].
|
|
18
|
+
*/
|
|
19
|
+
export function decodeBase58(encoded) {
|
|
20
|
+
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
21
|
+
const BASE = 58;
|
|
22
|
+
const bytes = [];
|
|
23
|
+
for (const char of encoded) {
|
|
24
|
+
const idx = ALPHABET.indexOf(char);
|
|
25
|
+
if (idx < 0)
|
|
26
|
+
throw new Error(`Invalid base58 character: ${char}`);
|
|
27
|
+
let carry = idx;
|
|
28
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
29
|
+
carry += bytes[j] * BASE;
|
|
30
|
+
bytes[j] = carry & 0xff;
|
|
31
|
+
carry >>= 8;
|
|
32
|
+
}
|
|
33
|
+
while (carry > 0) {
|
|
34
|
+
bytes.push(carry & 0xff);
|
|
35
|
+
carry >>= 8;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Count leading '1's = leading zero bytes
|
|
39
|
+
let leadingZeros = 0;
|
|
40
|
+
for (const char of encoded) {
|
|
41
|
+
if (char !== "1")
|
|
42
|
+
break;
|
|
43
|
+
leadingZeros++;
|
|
44
|
+
}
|
|
45
|
+
// Reverse the computed bytes and prepend leading zeros
|
|
46
|
+
const reversed = bytes.reverse();
|
|
47
|
+
const result = new Uint8Array(leadingZeros + reversed.length);
|
|
48
|
+
result.set(reversed, leadingZeros);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Decode a hex string to Uint8Array.
|
|
53
|
+
*/
|
|
54
|
+
function decodeHex(hex) {
|
|
55
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
56
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
57
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
58
|
+
bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse a Solana private key from multiple formats.
|
|
64
|
+
*
|
|
65
|
+
* Supports:
|
|
66
|
+
* - Hex string (64 chars = 32 bytes private key, 128 chars = 64 bytes keypair)
|
|
67
|
+
* - Base58 string: standard Solana wallet format (64-byte keypair)
|
|
68
|
+
* - JSON array: Solana CLI's ~/.config/solana/id.json format ([12, 34, ...])
|
|
69
|
+
*/
|
|
70
|
+
export function parsePrivateKey(input) {
|
|
71
|
+
const trimmed = input.trim();
|
|
72
|
+
// JSON array format: [12, 34, 56, ...]
|
|
73
|
+
if (trimmed.startsWith("[")) {
|
|
74
|
+
try {
|
|
75
|
+
const arr = JSON.parse(trimmed);
|
|
76
|
+
if (!Array.isArray(arr) || !arr.every((n) => typeof n === "number" && n >= 0 && n <= 255)) {
|
|
77
|
+
throw new Error("JSON array must contain numbers 0-255");
|
|
78
|
+
}
|
|
79
|
+
return new Uint8Array(arr);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
throw new Error(`Invalid JSON array key format: ${e instanceof Error ? e.message : e}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Hex format: 64 hex chars (32 bytes) or 128 hex chars (64 bytes)
|
|
86
|
+
const hexClean = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
|
|
87
|
+
if (/^[0-9a-f]+$/i.test(hexClean) && (hexClean.length === 64 || hexClean.length === 128)) {
|
|
88
|
+
return decodeHex(hexClean);
|
|
89
|
+
}
|
|
90
|
+
// Base58 format (default)
|
|
91
|
+
return decodeBase58(trimmed);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create a payment-aware fetch function for Solana x402 payments.
|
|
95
|
+
*
|
|
96
|
+
* @param privateKeyInput - Base58-encoded Solana keypair or JSON array
|
|
97
|
+
* @param maxPaymentUsd - Maximum payment amount in USD per request
|
|
98
|
+
* @returns A fetch function that auto-handles 402 → Solana USDC payment → retry
|
|
99
|
+
*/
|
|
100
|
+
export async function createSolanaX402Fetch(privateKeyInput, maxPaymentUsd = 1.0) {
|
|
101
|
+
// 1. Parse private key (hex, base58, or JSON array)
|
|
102
|
+
const keyBytes = parsePrivateKey(privateKeyInput);
|
|
103
|
+
// 2. Create a KeyPairSigner — 32 bytes = private key only, 64 bytes = full keypair
|
|
104
|
+
const signer = keyBytes.length === 32
|
|
105
|
+
? await createKeyPairSignerFromPrivateKeyBytes(keyBytes)
|
|
106
|
+
: await createKeyPairSignerFromBytes(keyBytes);
|
|
107
|
+
// 3. Convert to x402 SVM signer
|
|
108
|
+
const svmSigner = toClientSvmSigner(signer);
|
|
109
|
+
// 4. Create x402 client and register Solana scheme
|
|
110
|
+
// M2 fix: Explicitly specify mainnet only to prevent devnet/testnet payments
|
|
111
|
+
const client = new x402Client();
|
|
112
|
+
registerExactSvmScheme(client, {
|
|
113
|
+
signer: svmSigner,
|
|
114
|
+
policies: [createMaxPaymentPolicy(maxPaymentUsd)],
|
|
115
|
+
networks: [SOLANA_MAINNET],
|
|
116
|
+
});
|
|
117
|
+
console.error(`[x402-solana] Wallet: ${signer.address} on ${SOLANA_MAINNET} (max $${maxPaymentUsd}/req)`);
|
|
118
|
+
// 5. Wrap fetch with payment handling
|
|
119
|
+
return wrapFetchWithPayment(fetch, client);
|
|
120
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hugen/plugin-x402-solana
|
|
3
|
+
*
|
|
4
|
+
* Solana USDC payments for ElizaOS agents via the x402 protocol.
|
|
5
|
+
* Enables agents to pay for any x402-protected API using Solana USDC.
|
|
6
|
+
*
|
|
7
|
+
* The first Solana x402 payment plugin for ElizaOS.
|
|
8
|
+
*/
|
|
9
|
+
import type { Plugin } from "@elizaos/core";
|
|
10
|
+
export { getX402Fetch } from "./state.js";
|
|
11
|
+
export declare const x402SolanaPlugin: Plugin;
|
|
12
|
+
export default x402SolanaPlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hugen/plugin-x402-solana
|
|
3
|
+
*
|
|
4
|
+
* Solana USDC payments for ElizaOS agents via the x402 protocol.
|
|
5
|
+
* Enables agents to pay for any x402-protected API using Solana USDC.
|
|
6
|
+
*
|
|
7
|
+
* The first Solana x402 payment plugin for ElizaOS.
|
|
8
|
+
*/
|
|
9
|
+
import { fetchX402Action } from "./actions/fetch-x402.js";
|
|
10
|
+
import { createSolanaX402Fetch } from "./client.js";
|
|
11
|
+
import { configureSecurityPolicy, getMaxPaymentUsd } from "./security.js";
|
|
12
|
+
import { setX402Fetch, deleteX402Fetch } from "./state.js";
|
|
13
|
+
// Re-export for external consumers
|
|
14
|
+
export { getX402Fetch } from "./state.js";
|
|
15
|
+
export const x402SolanaPlugin = {
|
|
16
|
+
name: "x402-solana",
|
|
17
|
+
description: "Pay for x402-protected APIs with Solana USDC. " +
|
|
18
|
+
"Automatically handles 402 Payment Required responses by signing " +
|
|
19
|
+
"Solana USDC transfers and retrying. Supports all x402-enabled APIs.",
|
|
20
|
+
init: async (pluginConfig, runtime) => {
|
|
21
|
+
// Configure security policy per-runtime
|
|
22
|
+
configureSecurityPolicy(runtime, {
|
|
23
|
+
allowedDomains: pluginConfig.allowedDomains?.split(",").map((d) => d.trim()),
|
|
24
|
+
maxPaymentUsd: pluginConfig.maxPaymentUsd
|
|
25
|
+
? parseFloat(pluginConfig.maxPaymentUsd)
|
|
26
|
+
: undefined,
|
|
27
|
+
allowAnyDomain: pluginConfig.allowAnyDomain === "true",
|
|
28
|
+
fetchTimeoutMs: pluginConfig.fetchTimeoutMs
|
|
29
|
+
? parseInt(pluginConfig.fetchTimeoutMs, 10)
|
|
30
|
+
: undefined,
|
|
31
|
+
});
|
|
32
|
+
const privateKey = runtime.getSetting("SOLANA_PRIVATE_KEY") ??
|
|
33
|
+
runtime.getSetting("WALLET_PRIVATE_KEY");
|
|
34
|
+
if (!privateKey) {
|
|
35
|
+
console.error("[x402-solana] No SOLANA_PRIVATE_KEY or WALLET_PRIVATE_KEY found in settings. " +
|
|
36
|
+
"Plugin will be inactive.");
|
|
37
|
+
deleteX402Fetch(runtime);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const maxUsd = getMaxPaymentUsd(runtime);
|
|
42
|
+
const x402Fetch = await createSolanaX402Fetch(String(privateKey), maxUsd);
|
|
43
|
+
setX402Fetch(runtime, x402Fetch);
|
|
44
|
+
console.error("[x402-solana] Initialized — Solana USDC payments enabled");
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
deleteX402Fetch(runtime);
|
|
48
|
+
console.error("[x402-solana] Failed to initialize:", err);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
actions: [fetchX402Action],
|
|
52
|
+
};
|
|
53
|
+
export default x402SolanaPlugin;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation, SSRF protection, and payment policy for x402 fetch requests.
|
|
3
|
+
*
|
|
4
|
+
* Prevents:
|
|
5
|
+
* - SSRF via private/internal IPs (IPv4 + IPv6 + IPv4-mapped IPv6)
|
|
6
|
+
* - Wallet drain via unapproved domains or excessive payments
|
|
7
|
+
* - Prompt injection attacks that trick the agent into calling malicious URLs
|
|
8
|
+
*/
|
|
9
|
+
import type { IAgentRuntime } from "@elizaos/core";
|
|
10
|
+
import type { PaymentPolicy } from "@x402/fetch";
|
|
11
|
+
/** Default fetch timeout in ms. */
|
|
12
|
+
export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
|
|
13
|
+
export interface SecurityConfig {
|
|
14
|
+
allowedDomains?: string[];
|
|
15
|
+
maxPaymentUsd?: number;
|
|
16
|
+
allowAnyDomain?: boolean;
|
|
17
|
+
fetchTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function configureSecurityPolicy(runtime: IAgentRuntime, c: SecurityConfig): void;
|
|
20
|
+
/**
|
|
21
|
+
* Validate a URL before making an x402 fetch request.
|
|
22
|
+
* Throws if the URL is not allowed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function validateUrl(url: string, runtime?: IAgentRuntime): void;
|
|
25
|
+
/** Get the max payment USD threshold. */
|
|
26
|
+
export declare function getMaxPaymentUsd(runtime?: IAgentRuntime): number;
|
|
27
|
+
/** Get the fetch timeout in ms. */
|
|
28
|
+
export declare function getFetchTimeoutMs(runtime?: IAgentRuntime): number;
|
|
29
|
+
/**
|
|
30
|
+
* Create a PaymentPolicy that:
|
|
31
|
+
* 1. Only allows USDC payments (rejects other SPL tokens)
|
|
32
|
+
* 2. Rejects payments above a USD threshold
|
|
33
|
+
*
|
|
34
|
+
* USDC has 6 decimals: $1.00 = "1000000".
|
|
35
|
+
*/
|
|
36
|
+
export declare function createMaxPaymentPolicy(maxUsd: number): PaymentPolicy;
|
|
37
|
+
/** Mask query parameter values for safe logging. */
|
|
38
|
+
export declare function maskQueryParams(url: string): string;
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation, SSRF protection, and payment policy for x402 fetch requests.
|
|
3
|
+
*
|
|
4
|
+
* Prevents:
|
|
5
|
+
* - SSRF via private/internal IPs (IPv4 + IPv6 + IPv4-mapped IPv6)
|
|
6
|
+
* - Wallet drain via unapproved domains or excessive payments
|
|
7
|
+
* - Prompt injection attacks that trick the agent into calling malicious URLs
|
|
8
|
+
*/
|
|
9
|
+
/** Default allowed domains for x402 API calls. */
|
|
10
|
+
const DEFAULT_ALLOWED_DOMAINS = [
|
|
11
|
+
"scout.hugen.tokyo",
|
|
12
|
+
"weather.hugen.tokyo",
|
|
13
|
+
"mailcheck.hugen.tokyo",
|
|
14
|
+
"defi.hugen.tokyo",
|
|
15
|
+
"content.hugen.tokyo",
|
|
16
|
+
"domain.hugen.tokyo",
|
|
17
|
+
"visual.hugen.tokyo",
|
|
18
|
+
"intel.hugen.tokyo",
|
|
19
|
+
"gotobi.hugen.tokyo",
|
|
20
|
+
];
|
|
21
|
+
/** Private/internal IP patterns (applied to bare hostname, brackets stripped). */
|
|
22
|
+
const PRIVATE_IP_PATTERNS = [
|
|
23
|
+
/^127\./,
|
|
24
|
+
/^10\./,
|
|
25
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
26
|
+
/^192\.168\./,
|
|
27
|
+
/^0\./,
|
|
28
|
+
/^169\.254\./,
|
|
29
|
+
/^::1$/,
|
|
30
|
+
/^fc00:/i,
|
|
31
|
+
/^fd/i,
|
|
32
|
+
/^fe80:/i,
|
|
33
|
+
/^localhost$/i,
|
|
34
|
+
];
|
|
35
|
+
/** Default max payment per single request in USD. */
|
|
36
|
+
const DEFAULT_MAX_PAYMENT_USD = 1.0;
|
|
37
|
+
/** Default fetch timeout in ms. */
|
|
38
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
39
|
+
// --- Per-runtime config storage ---
|
|
40
|
+
const configMap = new WeakMap();
|
|
41
|
+
const EMPTY_CONFIG = {};
|
|
42
|
+
export function configureSecurityPolicy(runtime, c) {
|
|
43
|
+
configMap.set(runtime, c);
|
|
44
|
+
}
|
|
45
|
+
function getConfig(runtime) {
|
|
46
|
+
if (runtime) {
|
|
47
|
+
return configMap.get(runtime) ?? EMPTY_CONFIG;
|
|
48
|
+
}
|
|
49
|
+
return EMPTY_CONFIG;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Strip IPv6 brackets from hostname for pattern matching.
|
|
53
|
+
* `new URL("https://[::1]").hostname` returns `[::1]`.
|
|
54
|
+
*/
|
|
55
|
+
function bareHostname(hostname) {
|
|
56
|
+
return hostname.replace(/^\[|\]$/g, "");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Convert IPv4-mapped IPv6 hex to IPv4 dotted format.
|
|
60
|
+
* Node.js normalizes `::ffff:127.0.0.1` to `::ffff:7f00:1`.
|
|
61
|
+
* Returns null if not an IPv4-mapped address.
|
|
62
|
+
*/
|
|
63
|
+
function ipv4MappedToIpv4(bare) {
|
|
64
|
+
const m = bare.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
65
|
+
if (!m)
|
|
66
|
+
return null;
|
|
67
|
+
const hi = parseInt(m[1], 16);
|
|
68
|
+
const lo = parseInt(m[2], 16);
|
|
69
|
+
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validate a URL before making an x402 fetch request.
|
|
73
|
+
* Throws if the URL is not allowed.
|
|
74
|
+
*/
|
|
75
|
+
export function validateUrl(url, runtime) {
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = new URL(url);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
82
|
+
}
|
|
83
|
+
// Must be HTTPS
|
|
84
|
+
if (parsed.protocol !== "https:") {
|
|
85
|
+
throw new Error(`Only HTTPS URLs are allowed, got: ${parsed.protocol}`);
|
|
86
|
+
}
|
|
87
|
+
// Block private/internal IPs (strip brackets for IPv6)
|
|
88
|
+
const bare = bareHostname(parsed.hostname);
|
|
89
|
+
for (const pattern of PRIVATE_IP_PATTERNS) {
|
|
90
|
+
if (pattern.test(bare)) {
|
|
91
|
+
throw new Error(`Access to private/internal addresses is blocked: ${parsed.hostname}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// IPv4-mapped IPv6: Node normalizes ::ffff:127.0.0.1 → ::ffff:7f00:1 (hex)
|
|
95
|
+
// Decode back to IPv4 and re-check against IPv4 patterns
|
|
96
|
+
const mappedIpv4 = ipv4MappedToIpv4(bare);
|
|
97
|
+
if (mappedIpv4) {
|
|
98
|
+
for (const pattern of PRIVATE_IP_PATTERNS) {
|
|
99
|
+
if (pattern.test(mappedIpv4)) {
|
|
100
|
+
throw new Error(`Access to private/internal addresses is blocked: ${parsed.hostname}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Domain allowlist check (unless allowAnyDomain is set)
|
|
105
|
+
const cfg = getConfig(runtime);
|
|
106
|
+
if (!cfg.allowAnyDomain) {
|
|
107
|
+
const allowed = cfg.allowedDomains ?? DEFAULT_ALLOWED_DOMAINS;
|
|
108
|
+
const isAllowed = allowed.some((d) => bare === d || bare.endsWith(`.${d}`));
|
|
109
|
+
if (!isAllowed) {
|
|
110
|
+
throw new Error(`Domain not in allowlist: ${parsed.hostname}. ` +
|
|
111
|
+
`Allowed: ${allowed.join(", ")}. ` +
|
|
112
|
+
`Set allowAnyDomain: true in plugin config to disable this check.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Get the max payment USD threshold. */
|
|
117
|
+
export function getMaxPaymentUsd(runtime) {
|
|
118
|
+
return getConfig(runtime).maxPaymentUsd ?? DEFAULT_MAX_PAYMENT_USD;
|
|
119
|
+
}
|
|
120
|
+
/** Get the fetch timeout in ms. */
|
|
121
|
+
export function getFetchTimeoutMs(runtime) {
|
|
122
|
+
return getConfig(runtime).fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
123
|
+
}
|
|
124
|
+
/** USDC mint address on Solana mainnet. */
|
|
125
|
+
const USDC_SOLANA_MAINNET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
126
|
+
/**
|
|
127
|
+
* Create a PaymentPolicy that:
|
|
128
|
+
* 1. Only allows USDC payments (rejects other SPL tokens)
|
|
129
|
+
* 2. Rejects payments above a USD threshold
|
|
130
|
+
*
|
|
131
|
+
* USDC has 6 decimals: $1.00 = "1000000".
|
|
132
|
+
*/
|
|
133
|
+
export function createMaxPaymentPolicy(maxUsd) {
|
|
134
|
+
const maxBaseUnits = BigInt(Math.round(maxUsd * 1_000_000));
|
|
135
|
+
return (_version, requirements) => {
|
|
136
|
+
return requirements.filter((r) => {
|
|
137
|
+
try {
|
|
138
|
+
// R1-1 fix: Only allow USDC payments — reject other SPL tokens
|
|
139
|
+
if (r.asset && r.asset !== USDC_SOLANA_MAINNET) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
return BigInt(r.amount) <= maxBaseUnits;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Mask query parameter values for safe logging. */
|
|
151
|
+
export function maskQueryParams(url) {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = new URL(url);
|
|
154
|
+
const masked = new URL(parsed.origin + parsed.pathname);
|
|
155
|
+
for (const [key] of parsed.searchParams) {
|
|
156
|
+
masked.searchParams.set(key, "***");
|
|
157
|
+
}
|
|
158
|
+
return masked.toString();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return url.split("?")[0] + "?***";
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state for the x402-solana plugin.
|
|
3
|
+
*
|
|
4
|
+
* Extracted to break the circular dependency between index.ts and fetch-x402.ts.
|
|
5
|
+
* Both modules import from here instead of from each other.
|
|
6
|
+
*/
|
|
7
|
+
import type { IAgentRuntime } from "@elizaos/core";
|
|
8
|
+
type X402Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
9
|
+
/** Store a fetch instance for a runtime. */
|
|
10
|
+
export declare function setX402Fetch(runtime: IAgentRuntime, fetch: X402Fetch): void;
|
|
11
|
+
/** Remove a fetch instance for a runtime. */
|
|
12
|
+
export declare function deleteX402Fetch(runtime: IAgentRuntime): void;
|
|
13
|
+
/** Get the initialized x402 fetch for a runtime. Null if not configured. */
|
|
14
|
+
export declare function getX402Fetch(runtime: IAgentRuntime): X402Fetch | null;
|
|
15
|
+
export {};
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state for the x402-solana plugin.
|
|
3
|
+
*
|
|
4
|
+
* Extracted to break the circular dependency between index.ts and fetch-x402.ts.
|
|
5
|
+
* Both modules import from here instead of from each other.
|
|
6
|
+
*/
|
|
7
|
+
/** Per-runtime x402 fetch instances */
|
|
8
|
+
const fetchMap = new WeakMap();
|
|
9
|
+
/** Store a fetch instance for a runtime. */
|
|
10
|
+
export function setX402Fetch(runtime, fetch) {
|
|
11
|
+
fetchMap.set(runtime, fetch);
|
|
12
|
+
}
|
|
13
|
+
/** Remove a fetch instance for a runtime. */
|
|
14
|
+
export function deleteX402Fetch(runtime) {
|
|
15
|
+
fetchMap.delete(runtime);
|
|
16
|
+
}
|
|
17
|
+
/** Get the initialized x402 fetch for a runtime. Null if not configured. */
|
|
18
|
+
export function getX402Fetch(runtime) {
|
|
19
|
+
return fetchMap.get(runtime) ?? null;
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hugen/plugin-x402-solana",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Solana USDC payments for ElizaOS agents via x402 protocol — pay for any x402-protected API with Solana",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch",
|
|
19
|
+
"test": "tsc && node --test dist/__tests__/*.test.js",
|
|
20
|
+
"clean": "rm -rf dist node_modules"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20.18.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@elizaos/core": "2.0.0-alpha.3",
|
|
28
|
+
"@solana/signers": "^5.5.0",
|
|
29
|
+
"@x402/core": "~2.5.0",
|
|
30
|
+
"@x402/svm": "~2.5.0",
|
|
31
|
+
"@x402/fetch": "~2.5.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"@types/node": "^22.0.0"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"agentConfig": {
|
|
41
|
+
"pluginType": "elizaos:plugin:1.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|