x402-fl 0.0.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/.claude/settings.local.json +10 -0
- package/.env.example +4 -0
- package/.github/workflows/publish.yml +66 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/dist/commands/balance.d.ts +5 -0
- package/dist/commands/balance.js +46 -0
- package/dist/commands/dev.d.ts +6 -0
- package/dist/commands/dev.js +85 -0
- package/dist/commands/fund.d.ts +9 -0
- package/dist/commands/fund.js +131 -0
- package/dist/lib/abi.d.ts +22 -0
- package/dist/lib/abi.js +16 -0
- package/dist/lib/anvil.d.ts +8 -0
- package/dist/lib/anvil.js +51 -0
- package/dist/lib/chain.d.ts +11603 -0
- package/dist/lib/chain.js +30 -0
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +31 -0
- package/dist/lib/facilitator.d.ts +7 -0
- package/dist/lib/facilitator.js +93 -0
- package/package.json +41 -0
- package/src/cli.ts +162 -0
- package/src/commands/balance.ts +66 -0
- package/src/commands/dev.ts +112 -0
- package/src/commands/fund.ts +183 -0
- package/src/lib/abi.ts +16 -0
- package/src/lib/anvil.ts +66 -0
- package/src/lib/chain.ts +40 -0
- package/src/lib/config.ts +40 -0
- package/src/lib/facilitator.ts +129 -0
- package/tsconfig.json +17 -0
package/.env.example
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
concurrency:
|
|
8
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
9
|
+
cancel-in-progress: true
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
publish:
|
|
17
|
+
name: Publish [npm]
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
environment: NPM
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: pnpm/action-setup@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: "18"
|
|
28
|
+
registry-url: https://registry.npmjs.org
|
|
29
|
+
cache: pnpm
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: pnpm install --frozen-lockfile
|
|
33
|
+
|
|
34
|
+
- name: Extract and validate version from tag
|
|
35
|
+
id: info
|
|
36
|
+
run: |
|
|
37
|
+
TAG="${{ github.event.release.tag_name }}"
|
|
38
|
+
|
|
39
|
+
if [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$ ]]; then
|
|
40
|
+
VERSION="${BASH_REMATCH[1]}"
|
|
41
|
+
else
|
|
42
|
+
echo "Error: Tag must be in format v0.0.0 (e.g., v1.0.0, v1.0.0-beta.1)"
|
|
43
|
+
echo "Received: $TAG"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
48
|
+
|
|
49
|
+
if [[ "$VERSION" == *-* ]]; then
|
|
50
|
+
echo "dist_tag=next" >> $GITHUB_OUTPUT
|
|
51
|
+
else
|
|
52
|
+
echo "dist_tag=latest" >> $GITHUB_OUTPUT
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
- name: Build
|
|
56
|
+
run: pnpm build
|
|
57
|
+
|
|
58
|
+
- name: Set version
|
|
59
|
+
run: |
|
|
60
|
+
VERSION="${{ steps.info.outputs.version }}"
|
|
61
|
+
sed -i 's/"version": "0.0.0"/"version": "'"$VERSION"'"/g' package.json
|
|
62
|
+
|
|
63
|
+
- name: Publish
|
|
64
|
+
run: npm publish --access public --tag ${{ steps.info.outputs.dist_tag }} --provenance
|
|
65
|
+
env:
|
|
66
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kevzzsk
|
|
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,121 @@
|
|
|
1
|
+
# x402-fl
|
|
2
|
+
|
|
3
|
+
Local x402 facilitator for development and testing. Forks Base mainnet with Foundry Anvil and runs a local facilitator server. Fund any address with USDC via direct storage manipulation.
|
|
4
|
+
|
|
5
|
+
> **Warning**: This is for local development only. Do NOT use in production. It uses Anvil's well-known deterministic private keys, which are publicly known and have zero security.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
`x402-fl` spins up a complete local x402 payment environment in one command:
|
|
10
|
+
|
|
11
|
+
1. Forks Base mainnet using Anvil (so you get real USDC contract state)
|
|
12
|
+
2. Starts a local facilitator server that can verify and settle x402 payments
|
|
13
|
+
3. Provides a `fund` command to mint USDC to any address via Anvil storage manipulation
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Node.js 18+
|
|
18
|
+
- [pnpm](https://pnpm.io/)
|
|
19
|
+
- [Foundry](https://www.getfoundry.sh/introduction/installation) (provides `anvil`)
|
|
20
|
+
- A Base mainnet RPC URL (e.g. `https://mainnet.base.org`, Alchemy, or Infura)
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/anthropics/x402-fl.git
|
|
26
|
+
cd x402-fl
|
|
27
|
+
pnpm install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Create a `.env` file:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cp .env.example .env
|
|
34
|
+
# Edit .env and set BASE_RPC_URL
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Start the local environment:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This starts Anvil on port 8545 and launches the facilitator on port 4022.
|
|
44
|
+
|
|
45
|
+
Fund an account with USDC:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx tsx src/cli.ts fund 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 100
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## CLI Commands
|
|
52
|
+
|
|
53
|
+
### `dev`
|
|
54
|
+
|
|
55
|
+
Start Anvil fork + facilitator server for local x402 development.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pnpm dev [options]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Flag | Default | Description |
|
|
62
|
+
| ----------------------- | ---------------------- | --------------------- |
|
|
63
|
+
| `--port <number>` | `4022` | Facilitator HTTP port |
|
|
64
|
+
| `--anvil-port <number>` | `8545` | Anvil RPC port |
|
|
65
|
+
| `--rpc-url <url>` | `BASE_RPC_URL` env var | Base RPC URL to fork |
|
|
66
|
+
|
|
67
|
+
### `fund`
|
|
68
|
+
|
|
69
|
+
Fund any address with USDC on the local Anvil fork.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx tsx src/cli.ts fund <address> <amount> [options]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Argument / Flag | Default | Description |
|
|
76
|
+
| ----------------------- | -------- | ------------------------------------------------- |
|
|
77
|
+
| `<address>` | required | 0x-prefixed Ethereum address to fund |
|
|
78
|
+
| `<amount>` | required | USDC amount (human-readable, e.g. `100` or `1.5`) |
|
|
79
|
+
| `--anvil-port <number>` | `8545` | Anvil RPC port |
|
|
80
|
+
|
|
81
|
+
### `balance`
|
|
82
|
+
|
|
83
|
+
Check USDC balance for an address on the local Anvil fork.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx tsx src/cli.ts balance <address> [options]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Argument / Flag | Default | Description |
|
|
90
|
+
| ----------------------- | -------- | ---------------------------- |
|
|
91
|
+
| `<address>` | required | 0x-prefixed Ethereum address |
|
|
92
|
+
| `--anvil-port <number>` | `8545` | Anvil RPC port |
|
|
93
|
+
|
|
94
|
+
## Test Accounts
|
|
95
|
+
|
|
96
|
+
The facilitator uses Anvil's default deterministic account (index 0). **Do not use these keys for anything real.**
|
|
97
|
+
|
|
98
|
+
| Role | Address |
|
|
99
|
+
| ----------- | -------------------------------------------- |
|
|
100
|
+
| Facilitator | `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` |
|
|
101
|
+
|
|
102
|
+
Any address can be funded using `x402-fl fund`.
|
|
103
|
+
|
|
104
|
+
## How it works
|
|
105
|
+
|
|
106
|
+
Anvil forks Base mainnet at the latest block, giving you a local copy of all on-chain state including the USDC contract. The `fund` command uses `anvil_setStorageAt` to directly write ERC-20 balance values into contract storage — it auto-detects the correct storage slot by probing common mapping layouts (slots 0–20). The facilitator server exposes `/verify`, `/settle`, `/supported`, and `/health` endpoints using the `@x402/core` and `@x402/evm` packages, operating against the local Anvil fork instead of real Base mainnet.
|
|
107
|
+
|
|
108
|
+
## Roadmap
|
|
109
|
+
|
|
110
|
+
- [ ] Custom ERC-20 token support
|
|
111
|
+
- [ ] Custom Fork url
|
|
112
|
+
- [ ] Dockerised anvil node
|
|
113
|
+
- [ ] Testcontainers for facilitator + anvil for deterministic testing env
|
|
114
|
+
|
|
115
|
+
## Issues
|
|
116
|
+
|
|
117
|
+
Found a bug or have a feature request? [Open an issue](https://github.com/anthropics/x402-fl/issues).
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import { defaults } from "./lib/config.js";
|
|
6
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
7
|
+
dotenv.config();
|
|
8
|
+
function parsePort(value) {
|
|
9
|
+
const port = parseInt(value, 10);
|
|
10
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
11
|
+
throw new InvalidArgumentError("must be a number between 1 and 65535");
|
|
12
|
+
}
|
|
13
|
+
return port;
|
|
14
|
+
}
|
|
15
|
+
function parseAddress(value) {
|
|
16
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(value)) {
|
|
17
|
+
throw new InvalidArgumentError("must be a valid 0x-prefixed Ethereum address (42 characters)");
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function parseAmount(value) {
|
|
22
|
+
if (!/^\d+(\.\d+)?$/.test(value) || Number(value) <= 0) {
|
|
23
|
+
throw new InvalidArgumentError("must be a positive number (e.g. '100' or '1.5')");
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
const program = new Command();
|
|
28
|
+
program
|
|
29
|
+
.name("x402-fl")
|
|
30
|
+
.description("Local x402 facilitator dev tool\n\n" +
|
|
31
|
+
" Spin up a local Anvil fork and x402 facilitator server for development.\n" +
|
|
32
|
+
" Requires `anvil` (from Foundry) to be installed and available in PATH.\n\n" +
|
|
33
|
+
" Quick start:\n" +
|
|
34
|
+
" $ x402-fl dev --rpc-url https://mainnet.base.org\n" +
|
|
35
|
+
" $ x402-fl fund 0xYourAddress 100")
|
|
36
|
+
.version(pkg.version)
|
|
37
|
+
.showHelpAfterError(true)
|
|
38
|
+
.configureHelp({ showGlobalOptions: true });
|
|
39
|
+
program
|
|
40
|
+
.command("dev")
|
|
41
|
+
.description("Start Anvil fork + facilitator server for local x402 development")
|
|
42
|
+
.option("--port <number>", `facilitator server port (default: ${defaults.facilitatorPort})`, parsePort, defaults.facilitatorPort)
|
|
43
|
+
.option("--anvil-port <number>", `Anvil JSON-RPC port (default: ${defaults.anvilPort})`, parsePort, defaults.anvilPort)
|
|
44
|
+
.option("--rpc-url <url>", "Base mainnet RPC URL to fork (or set BASE_RPC_URL in .env)")
|
|
45
|
+
.addHelpText("after", `
|
|
46
|
+
Examples:
|
|
47
|
+
$ x402-fl dev --rpc-url https://mainnet.base.org
|
|
48
|
+
$ x402-fl dev --rpc-url $BASE_RPC_URL --port 5000 --anvil-port 9545
|
|
49
|
+
|
|
50
|
+
Environment variables:
|
|
51
|
+
BASE_RPC_URL Fallback RPC URL when --rpc-url is not provided`)
|
|
52
|
+
.action(async (opts) => {
|
|
53
|
+
const rpcUrl = opts.rpcUrl || process.env.BASE_RPC_URL;
|
|
54
|
+
if (!rpcUrl) {
|
|
55
|
+
program.error("missing required RPC URL\n\n" +
|
|
56
|
+
" Provide one of:\n" +
|
|
57
|
+
" --rpc-url <url> pass directly as a flag\n" +
|
|
58
|
+
" BASE_RPC_URL=<url> set in .env or environment\n\n" +
|
|
59
|
+
" Example:\n" +
|
|
60
|
+
" $ x402-fl dev --rpc-url https://mainnet.base.org");
|
|
61
|
+
}
|
|
62
|
+
const { devCommand } = await import("./commands/dev.js");
|
|
63
|
+
await devCommand({
|
|
64
|
+
port: opts.port,
|
|
65
|
+
anvilPort: opts.anvilPort,
|
|
66
|
+
rpcUrl,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
program
|
|
70
|
+
.command("fund")
|
|
71
|
+
.description("Fund an address with USDC on local Anvil")
|
|
72
|
+
.argument("<address>", "0x-prefixed Ethereum address to fund", parseAddress)
|
|
73
|
+
.argument("<amount>", "USDC amount (e.g. '100' or '1.5')", parseAmount)
|
|
74
|
+
.option("--anvil-port <number>", `Anvil JSON-RPC port`, parsePort, defaults.anvilPort)
|
|
75
|
+
.addHelpText("after", `
|
|
76
|
+
Examples:
|
|
77
|
+
$ x402-fl fund 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 100
|
|
78
|
+
$ x402-fl fund 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1.5 --anvil-port 9545`)
|
|
79
|
+
.action(async (address, amount, opts) => {
|
|
80
|
+
const { fundCommand } = await import("./commands/fund.js");
|
|
81
|
+
await fundCommand({
|
|
82
|
+
address,
|
|
83
|
+
amount,
|
|
84
|
+
anvilPort: opts.anvilPort,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
program
|
|
88
|
+
.command("balance")
|
|
89
|
+
.description("Get USDC balance for an address on local Anvil")
|
|
90
|
+
.argument("<address>", "0x-prefixed Ethereum address to check", parseAddress)
|
|
91
|
+
.option("--anvil-port <number>", `Anvil JSON-RPC port`, parsePort, defaults.anvilPort)
|
|
92
|
+
.addHelpText("after", `
|
|
93
|
+
Examples:
|
|
94
|
+
$ x402-fl balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
|
|
95
|
+
$ x402-fl balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --anvil-port 9545`)
|
|
96
|
+
.action(async (address, opts) => {
|
|
97
|
+
const { balanceCommand } = await import("./commands/balance.js");
|
|
98
|
+
await balanceCommand({
|
|
99
|
+
address,
|
|
100
|
+
anvilPort: opts.anvilPort,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
program.parse();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import { createPublicClient, fetchChainId } from "../lib/chain.js";
|
|
4
|
+
import { DEFAULT_DECIMALS, formatTokenAmount, USDC_ADDRESS, } from "../lib/config.js";
|
|
5
|
+
import { ERC20_ABI } from "../lib/abi.js";
|
|
6
|
+
export async function balanceCommand(options) {
|
|
7
|
+
const { address, anvilPort } = options;
|
|
8
|
+
const rpcUrl = `http://localhost:${anvilPort}`;
|
|
9
|
+
let chainId;
|
|
10
|
+
try {
|
|
11
|
+
chainId = await fetchChainId(rpcUrl);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
console.error(`Error: Could not connect to Anvil at ${rpcUrl}.\n` +
|
|
15
|
+
`Make sure Anvil is running (e.g. "x402-fl dev" or "anvil --fork-url ...").`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const client = createPublicClient(rpcUrl, chainId);
|
|
19
|
+
let decimals = DEFAULT_DECIMALS;
|
|
20
|
+
try {
|
|
21
|
+
decimals = await client.readContract({
|
|
22
|
+
address: USDC_ADDRESS,
|
|
23
|
+
abi: ERC20_ABI,
|
|
24
|
+
functionName: "decimals",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Non-standard token without decimals(); fall back to default
|
|
29
|
+
}
|
|
30
|
+
const balance = await client.readContract({
|
|
31
|
+
address: USDC_ADDRESS,
|
|
32
|
+
abi: ERC20_ABI,
|
|
33
|
+
functionName: "balanceOf",
|
|
34
|
+
args: [address],
|
|
35
|
+
});
|
|
36
|
+
console.log(boxen([
|
|
37
|
+
`${chalk.dim("Token")} ${USDC_ADDRESS}`,
|
|
38
|
+
`${chalk.dim("Address")} ${address}`,
|
|
39
|
+
`${chalk.dim("Balance")} ${chalk.green.bold(formatTokenAmount(balance, decimals))} USDC`,
|
|
40
|
+
].join("\n"), {
|
|
41
|
+
title: "USDC Balance",
|
|
42
|
+
padding: 1,
|
|
43
|
+
borderStyle: "round",
|
|
44
|
+
borderColor: "cyan",
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import { startAnvil, waitForAnvil } from "../lib/anvil.js";
|
|
4
|
+
import { startFacilitator } from "../lib/facilitator.js";
|
|
5
|
+
import { fetchChainId } from "../lib/chain.js";
|
|
6
|
+
import { accounts, networkId, USDC_ADDRESS } from "../lib/config.js";
|
|
7
|
+
export async function devCommand(options) {
|
|
8
|
+
const localRpcUrl = `http://localhost:${options.anvilPort}`;
|
|
9
|
+
let anvilProc = null;
|
|
10
|
+
let server = null;
|
|
11
|
+
function cleanup() {
|
|
12
|
+
console.log("\nShutting down...");
|
|
13
|
+
let pending = 0;
|
|
14
|
+
const done = () => { if (--pending <= 0)
|
|
15
|
+
process.exit(0); };
|
|
16
|
+
if (server) {
|
|
17
|
+
pending++;
|
|
18
|
+
server.close(() => done());
|
|
19
|
+
server = null;
|
|
20
|
+
}
|
|
21
|
+
if (anvilProc) {
|
|
22
|
+
pending++;
|
|
23
|
+
anvilProc.on("exit", () => done());
|
|
24
|
+
anvilProc.kill("SIGTERM");
|
|
25
|
+
anvilProc = null;
|
|
26
|
+
}
|
|
27
|
+
if (pending === 0)
|
|
28
|
+
process.exit(0);
|
|
29
|
+
// Force exit after 3s if graceful shutdown hangs
|
|
30
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
31
|
+
}
|
|
32
|
+
process.on("SIGINT", cleanup);
|
|
33
|
+
process.on("SIGTERM", cleanup);
|
|
34
|
+
// 0. Detect chain ID from fork RPC
|
|
35
|
+
console.log(chalk.dim(`Detecting chain ID from ${options.rpcUrl}...`));
|
|
36
|
+
const chainId = await fetchChainId(options.rpcUrl);
|
|
37
|
+
console.log(chalk.dim(`Detected chain ID: ${chainId}`));
|
|
38
|
+
// 1. Start Anvil
|
|
39
|
+
console.log(chalk.dim(`Starting Anvil (forking chain ${chainId}, port ${options.anvilPort})...`));
|
|
40
|
+
anvilProc = startAnvil({
|
|
41
|
+
forkUrl: options.rpcUrl,
|
|
42
|
+
port: options.anvilPort,
|
|
43
|
+
chainId,
|
|
44
|
+
});
|
|
45
|
+
anvilProc.on("exit", (code) => {
|
|
46
|
+
if (code !== null && code !== 0) {
|
|
47
|
+
console.error(chalk.red(`Anvil exited with code ${code}`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// 2. Wait for Anvil to be ready
|
|
52
|
+
console.log(chalk.dim("Waiting for Anvil to be ready..."));
|
|
53
|
+
await waitForAnvil(localRpcUrl);
|
|
54
|
+
console.log(chalk.dim("Anvil is ready."));
|
|
55
|
+
// 3. Start facilitator
|
|
56
|
+
console.log(chalk.dim(`Starting facilitator on port ${options.port}...`));
|
|
57
|
+
server = await startFacilitator({
|
|
58
|
+
port: options.port,
|
|
59
|
+
rpcUrl: localRpcUrl,
|
|
60
|
+
chainId,
|
|
61
|
+
});
|
|
62
|
+
const network = networkId(chainId);
|
|
63
|
+
// 4. Print summary
|
|
64
|
+
console.log(boxen([
|
|
65
|
+
`${chalk.dim("Anvil RPC")} ${chalk.cyan(localRpcUrl)}`,
|
|
66
|
+
`${chalk.dim("Facilitator")} ${chalk.cyan(`http://localhost:${options.port}`)}`,
|
|
67
|
+
`${chalk.dim("Network")} ${network}`,
|
|
68
|
+
`${chalk.dim("Chain ID")} ${chainId}`,
|
|
69
|
+
"",
|
|
70
|
+
chalk.dim("Accounts"),
|
|
71
|
+
` ${chalk.dim("Facilitator")} ${accounts.facilitator.address}`,
|
|
72
|
+
"",
|
|
73
|
+
`${chalk.dim("USDC")} ${USDC_ADDRESS}`,
|
|
74
|
+
"",
|
|
75
|
+
`${chalk.dim("Fund account with:")}`,
|
|
76
|
+
`$ ${chalk.white("x402-fl fund <address> <amount>")}`,
|
|
77
|
+
"",
|
|
78
|
+
chalk.dim("Press Ctrl+C to stop."),
|
|
79
|
+
].join("\n"), {
|
|
80
|
+
title: "x402-fl dev",
|
|
81
|
+
padding: 1,
|
|
82
|
+
borderStyle: "round",
|
|
83
|
+
borderColor: "green",
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface FundOptions {
|
|
2
|
+
address: `0x${string}`;
|
|
3
|
+
amount: string;
|
|
4
|
+
anvilPort: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Fund any address with ERC-20 tokens by directly manipulating Anvil storage.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fundCommand(options: FundOptions): Promise<void>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import { createTestClient, http, publicActions, keccak256, encodePacked, pad, toHex, } from "viem";
|
|
4
|
+
import { createLocalChain, fetchChainId } from "../lib/chain.js";
|
|
5
|
+
import { DEFAULT_DECIMALS, formatTokenAmount, parseTokenAmount, USDC_ADDRESS } from "../lib/config.js";
|
|
6
|
+
import { ERC20_ABI } from "../lib/abi.js";
|
|
7
|
+
/**
|
|
8
|
+
* Compute the storage slot for `mapping(address => uint256)` at a given
|
|
9
|
+
* base slot index. Solidity stores mapping values at keccak256(key . slot).
|
|
10
|
+
*/
|
|
11
|
+
function balanceSlot(address, baseSlot) {
|
|
12
|
+
return keccak256(encodePacked(["bytes32", "bytes32"], [pad(address, { size: 32 }), pad(toHex(baseSlot), { size: 32 })]));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Probe common mapping slots (0-20) to find the correct `balanceOf` storage
|
|
16
|
+
* slot for an arbitrary ERC-20 contract.
|
|
17
|
+
*
|
|
18
|
+
* Strategy: for each candidate base slot we write a known sentinel value into
|
|
19
|
+
* the computed mapping slot, then check whether `balanceOf` returns that
|
|
20
|
+
* sentinel. The original value is always restored before moving on.
|
|
21
|
+
*/
|
|
22
|
+
async function findBalanceSlot(client, contractAddress, probeAddress) {
|
|
23
|
+
const sentinel = pad(toHex(31337n * 10n ** 18n), { size: 32 });
|
|
24
|
+
for (let slot = 0; slot <= 20; slot++) {
|
|
25
|
+
const storageKey = balanceSlot(probeAddress, slot);
|
|
26
|
+
// Save the original value so we can restore it.
|
|
27
|
+
const original = await client.getStorageAt({
|
|
28
|
+
address: contractAddress,
|
|
29
|
+
slot: storageKey,
|
|
30
|
+
});
|
|
31
|
+
// Write sentinel
|
|
32
|
+
await client.request({
|
|
33
|
+
method: "anvil_setStorageAt",
|
|
34
|
+
params: [contractAddress, storageKey, sentinel],
|
|
35
|
+
});
|
|
36
|
+
// Read balanceOf and see if it matches
|
|
37
|
+
const balance = await client.readContract({
|
|
38
|
+
address: contractAddress,
|
|
39
|
+
abi: ERC20_ABI,
|
|
40
|
+
functionName: "balanceOf",
|
|
41
|
+
args: [probeAddress],
|
|
42
|
+
});
|
|
43
|
+
// Restore original value regardless of outcome
|
|
44
|
+
await client.request({
|
|
45
|
+
method: "anvil_setStorageAt",
|
|
46
|
+
params: [contractAddress, storageKey, original ?? pad("0x0", { size: 32 })],
|
|
47
|
+
});
|
|
48
|
+
if (balance === 31337n * 10n ** 18n) {
|
|
49
|
+
return slot;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Could not detect balanceOf storage slot for contract ${contractAddress}. ` +
|
|
53
|
+
`Probed slots 0-20 with no match.`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fund any address with ERC-20 tokens by directly manipulating Anvil storage.
|
|
57
|
+
*/
|
|
58
|
+
export async function fundCommand(options) {
|
|
59
|
+
const { address, amount, anvilPort } = options;
|
|
60
|
+
const contractAddress = USDC_ADDRESS;
|
|
61
|
+
const rpcUrl = `http://localhost:${anvilPort}`;
|
|
62
|
+
// Detect chain ID from running Anvil instance
|
|
63
|
+
let chainId;
|
|
64
|
+
try {
|
|
65
|
+
chainId = await fetchChainId(rpcUrl);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
console.error(`Error: Could not connect to Anvil at ${rpcUrl}.\n` +
|
|
69
|
+
`Make sure Anvil is running (e.g. "x402-fl dev" or "anvil --fork-url ...").`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
// Create a viem TestClient (Anvil mode) with public actions
|
|
73
|
+
const chain = createLocalChain(rpcUrl, chainId);
|
|
74
|
+
const client = createTestClient({
|
|
75
|
+
mode: "anvil",
|
|
76
|
+
chain,
|
|
77
|
+
transport: http(rpcUrl),
|
|
78
|
+
}).extend(publicActions);
|
|
79
|
+
// Read token decimals from ERC-20 metadata, default to 6
|
|
80
|
+
let decimals = DEFAULT_DECIMALS;
|
|
81
|
+
try {
|
|
82
|
+
decimals = await client.readContract({
|
|
83
|
+
address: contractAddress,
|
|
84
|
+
abi: ERC20_ABI,
|
|
85
|
+
functionName: "decimals",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Non-standard token without decimals(); fall back to default
|
|
90
|
+
}
|
|
91
|
+
const formatAmount = (raw) => formatTokenAmount(raw, decimals);
|
|
92
|
+
// Read current balance
|
|
93
|
+
const before = await client.readContract({
|
|
94
|
+
address: contractAddress,
|
|
95
|
+
abi: ERC20_ABI,
|
|
96
|
+
functionName: "balanceOf",
|
|
97
|
+
args: [address],
|
|
98
|
+
});
|
|
99
|
+
// Detect the storage slot
|
|
100
|
+
const baseSlot = await findBalanceSlot(client, contractAddress, address);
|
|
101
|
+
const addAmount = parseTokenAmount(amount, decimals);
|
|
102
|
+
const newBalance = before + addAmount;
|
|
103
|
+
// Write the new balance to storage
|
|
104
|
+
const storageKey = balanceSlot(address, baseSlot);
|
|
105
|
+
const encodedBalance = pad(toHex(newBalance), { size: 32 });
|
|
106
|
+
await client.request({
|
|
107
|
+
method: "anvil_setStorageAt",
|
|
108
|
+
params: [contractAddress, storageKey, encodedBalance],
|
|
109
|
+
});
|
|
110
|
+
// Verify the balance was updated correctly
|
|
111
|
+
const after = await client.readContract({
|
|
112
|
+
address: contractAddress,
|
|
113
|
+
abi: ERC20_ABI,
|
|
114
|
+
functionName: "balanceOf",
|
|
115
|
+
args: [address],
|
|
116
|
+
});
|
|
117
|
+
if (after !== newBalance) {
|
|
118
|
+
console.error(`Warning: Balance mismatch after storage write.\n` +
|
|
119
|
+
` Expected: ${formatAmount(newBalance)}\n` +
|
|
120
|
+
` Got: ${formatAmount(after)}`);
|
|
121
|
+
}
|
|
122
|
+
// Print confirmation
|
|
123
|
+
console.log(boxen([
|
|
124
|
+
`${chalk.dim("Token")} ${contractAddress}`,
|
|
125
|
+
`${chalk.dim("Address")} ${address}`,
|
|
126
|
+
"",
|
|
127
|
+
`${chalk.dim("Before")} ${formatAmount(before)} USDC`,
|
|
128
|
+
`${chalk.dim("Added")} ${chalk.yellow("+" + formatAmount(addAmount))} USDC`,
|
|
129
|
+
`${chalk.dim("After")} ${chalk.green.bold(formatAmount(after))} USDC`,
|
|
130
|
+
].join("\n"), { title: "Funded", padding: 1, borderStyle: "round", borderColor: "green" }));
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const ERC20_ABI: readonly [{
|
|
2
|
+
readonly inputs: readonly [{
|
|
3
|
+
readonly name: "account";
|
|
4
|
+
readonly type: "address";
|
|
5
|
+
}];
|
|
6
|
+
readonly name: "balanceOf";
|
|
7
|
+
readonly outputs: readonly [{
|
|
8
|
+
readonly name: "";
|
|
9
|
+
readonly type: "uint256";
|
|
10
|
+
}];
|
|
11
|
+
readonly stateMutability: "view";
|
|
12
|
+
readonly type: "function";
|
|
13
|
+
}, {
|
|
14
|
+
readonly inputs: readonly [];
|
|
15
|
+
readonly name: "decimals";
|
|
16
|
+
readonly outputs: readonly [{
|
|
17
|
+
readonly name: "";
|
|
18
|
+
readonly type: "uint8";
|
|
19
|
+
}];
|
|
20
|
+
readonly stateMutability: "view";
|
|
21
|
+
readonly type: "function";
|
|
22
|
+
}];
|
package/dist/lib/abi.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const ERC20_ABI = [
|
|
2
|
+
{
|
|
3
|
+
inputs: [{ name: "account", type: "address" }],
|
|
4
|
+
name: "balanceOf",
|
|
5
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
6
|
+
stateMutability: "view",
|
|
7
|
+
type: "function",
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
inputs: [],
|
|
11
|
+
name: "decimals",
|
|
12
|
+
outputs: [{ name: "", type: "uint8" }],
|
|
13
|
+
stateMutability: "view",
|
|
14
|
+
type: "function",
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ChildProcess } from "child_process";
|
|
2
|
+
export interface AnvilOptions {
|
|
3
|
+
forkUrl: string;
|
|
4
|
+
port: number;
|
|
5
|
+
chainId: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function startAnvil(options: AnvilOptions): ChildProcess;
|
|
8
|
+
export declare function waitForAnvil(rpcUrl: string, timeoutMs?: number): Promise<void>;
|