nodpay 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/AGENT-NOTES.md +53 -0
- package/README.md +236 -0
- package/SKILL.md +236 -0
- package/bin/nodpay.mjs +25 -0
- package/package.json +24 -0
- package/references/networks.json +91 -0
- package/scripts/predict-passkey.mjs +167 -0
- package/scripts/propose.mjs +487 -0
package/AGENT-NOTES.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Agent Operational Notes
|
|
2
|
+
|
|
3
|
+
Advanced patterns and edge cases. Read SKILL.md first.
|
|
4
|
+
|
|
5
|
+
## Nonce Management
|
|
6
|
+
|
|
7
|
+
- **Auto-increment**: propose without `--nonce` → script fetches on-chain nonce, auto-increments past pending ops
|
|
8
|
+
- **Replace**: use `--nonce N` with same nonce as pending tx → both appear in dashboard, user picks one
|
|
9
|
+
- **Gas reuse**: when nonce > on-chain nonce, bundler rejects gas estimation (`AA25`). Script auto-reuses gas from the first pending op.
|
|
10
|
+
|
|
11
|
+
## Status Lifecycle
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pending → submitted → executed
|
|
15
|
+
↘ rejected
|
|
16
|
+
↘ replaced (another tx at same nonce was executed)
|
|
17
|
+
↘ invalidated (cascade from lower nonce rejection)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Multi-Wallet
|
|
21
|
+
|
|
22
|
+
One agent can serve multiple wallets. Different user devices → different passkeys → different Safe addresses. Always confirm which wallet before proposing.
|
|
23
|
+
|
|
24
|
+
## EOA Wallets
|
|
25
|
+
|
|
26
|
+
For users who prefer MetaMask/browser wallet over passkey:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node scripts/propose.mjs \
|
|
30
|
+
--safe <WALLET_ADDRESS> \
|
|
31
|
+
--to <RECIPIENT> \
|
|
32
|
+
--value-eth <AMOUNT> \
|
|
33
|
+
--user-signer <USER_EOA_ADDRESS> \
|
|
34
|
+
--recovery <RECOVERY_SIGNER> \
|
|
35
|
+
--signer-type eoa
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Owner Ordering (CREATE2)
|
|
39
|
+
|
|
40
|
+
Safe address is deterministically derived. Owner order is fixed:
|
|
41
|
+
|
|
42
|
+
- **Passkey**: `[agent, recovery, SharedSigner]`
|
|
43
|
+
- **EOA**: `[userSigner, agent, recovery]`
|
|
44
|
+
|
|
45
|
+
Wrong order → wrong address. The propose script handles this automatically.
|
|
46
|
+
|
|
47
|
+
## API Reference
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
GET /api/txs?safe=<addr> # list all ops for a wallet
|
|
51
|
+
GET /api/txs?safe=<addr>&status=pending # filter by status
|
|
52
|
+
GET /api/tx/<shortHash> # get single op by hash prefix
|
|
53
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nodpay
|
|
3
|
+
description: Propose on-chain payments from a shared wallet. Use when user asks to send crypto, make a payment, or create a shared wallet.
|
|
4
|
+
metadata:
|
|
5
|
+
{ "openclaw": { "requires": { "env": ["NODPAY_AGENT_KEY"] }, "primaryEnv": "NODPAY_AGENT_KEY" } }
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# NodPay — Agent Wallet
|
|
9
|
+
|
|
10
|
+
> Two minds, one wallet.
|
|
11
|
+
|
|
12
|
+
NodPay lets you (an AI agent) propose on-chain payments from a shared wallet. Your human co-signs with one tap. You cannot spend without their approval.
|
|
13
|
+
|
|
14
|
+
**When to use**: User asks you to send crypto, make a payment, or create a shared wallet.
|
|
15
|
+
|
|
16
|
+
## How It Works
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
You (agent) NodPay User (human)
|
|
20
|
+
│ │ │
|
|
21
|
+
├─ generate key ──►│ │
|
|
22
|
+
├─ send link ──────┼──────────────────►│
|
|
23
|
+
│ │◄── create wallet ─┤
|
|
24
|
+
│◄── wallet info ──┤ │
|
|
25
|
+
│ │ │
|
|
26
|
+
├─ propose tx ────►│ │
|
|
27
|
+
│ ├── approve link ───►│
|
|
28
|
+
│ │◄── sign + submit ─┤
|
|
29
|
+
│ │ │
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The wallet is a **Safe 2-of-3 multisig**. You hold 1 key, the user holds 2 (passkey + recovery). You cannot move funds alone.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Setup (one-time)
|
|
37
|
+
|
|
38
|
+
### 1. Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/xhyumiracle/nodpay.git
|
|
42
|
+
cd nodpay/skill && npm install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Set `NODPAY_AGENT_KEY` in your environment
|
|
46
|
+
|
|
47
|
+
Generate one if you don't have it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
node -e "const w=require('ethers').Wallet.createRandom();console.log('Address:',w.address,'\nKey:',w.privateKey)"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Store the private key wherever your agent framework manages secrets (e.g. `.env`, system env vars). The address is your public agent identity.
|
|
54
|
+
|
|
55
|
+
No bundler key needed — NodPay proxies bundler calls for you.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Create a wallet
|
|
62
|
+
|
|
63
|
+
Send the user this link:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
https://nodpay.ai/?agent=YOUR_AGENT_ADDRESS
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
User opens → creates passkey → saves recovery phrase → gets a wallet. Takes 30 seconds.
|
|
70
|
+
|
|
71
|
+
The user will copy wallet info back to you:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
NodPay Wallet (Safe): 0x...
|
|
75
|
+
Passkey X: 0x...
|
|
76
|
+
Passkey Y: 0x...
|
|
77
|
+
Agent: 0x...
|
|
78
|
+
Recovery Signer: 0x...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Store all fields** — you need them for proposing transactions.
|
|
82
|
+
|
|
83
|
+
#### Wallet file management
|
|
84
|
+
|
|
85
|
+
Store wallet info in `.nodpay/wallets/` in your workspace root (separate from skill code):
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
.nodpay/wallets/
|
|
89
|
+
0xAbC...123.json # one file per wallet, named by Safe address
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Each wallet file:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"safe": "0x...",
|
|
97
|
+
"signerType": "passkey",
|
|
98
|
+
"passkeyX": "0x...",
|
|
99
|
+
"passkeyY": "0x...",
|
|
100
|
+
"recovery": "0x...",
|
|
101
|
+
"chain": "sepolia",
|
|
102
|
+
"createdAt": "2025-01-01"
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For EOA wallets, replace passkey fields with `"userSigner": "0x..."`.
|
|
107
|
+
|
|
108
|
+
One agent key serves all wallets — multi-wallet is handled user-side (different passkeys/recovery keys → different Safe addresses, same agent).
|
|
109
|
+
|
|
110
|
+
**⚠️ Verify the Agent address matches yours.** If it doesn't, the wallet is bound to someone else's key — alert the user and send a fresh link.
|
|
111
|
+
|
|
112
|
+
**Multiple wallets**: A user may have multiple wallets (different devices, different passkeys). Before proposing, confirm which wallet to use — especially if you manage more than one.
|
|
113
|
+
|
|
114
|
+
### Propose a transaction
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
NODPAY_AGENT_KEY=0x... \
|
|
118
|
+
node scripts/propose.mjs \
|
|
119
|
+
--safe <WALLET_ADDRESS> \
|
|
120
|
+
--to <RECIPIENT> \
|
|
121
|
+
--value-eth <AMOUNT> \
|
|
122
|
+
--passkey-x <PASSKEY_X> \
|
|
123
|
+
--passkey-y <PASSKEY_Y> \
|
|
124
|
+
--recovery <RECOVERY_SIGNER> \
|
|
125
|
+
--signer-type passkey
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The script outputs JSON with an `approveUrl`. Send it to the user:
|
|
129
|
+
|
|
130
|
+
> 💰 Payment: 0.01 ETH → 0xRecipient...
|
|
131
|
+
> 👉 Approve: https://nodpay.ai/approve?safeOpHash=0x...
|
|
132
|
+
|
|
133
|
+
**First transaction deploys the wallet on-chain.** Pass `--passkey-x`, `--passkey-y`, and `--recovery` for the first tx. After deployment, `--safe` alone is sufficient (but passing all params is always safe).
|
|
134
|
+
|
|
135
|
+
### Check balance
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
curl -s -X POST https://ethereum-sepolia-rpc.publicnode.com \
|
|
139
|
+
-H "Content-Type: application/json" \
|
|
140
|
+
-d '{"jsonrpc":"2.0","method":"eth_getBalance","params":["<WALLET_ADDRESS>","latest"],"id":1}'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If balance is 0, remind the user to deposit before proposing.
|
|
144
|
+
|
|
145
|
+
### Check pending transactions
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
curl https://nodpay.ai/api/txs?safe=<WALLET_ADDRESS>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Always check before proposing — this tells you the current nonce, pending ops, and wallet status.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Script Reference
|
|
156
|
+
|
|
157
|
+
### Flags
|
|
158
|
+
|
|
159
|
+
| Flag | Required | Description |
|
|
160
|
+
|------|----------|-------------|
|
|
161
|
+
| `--safe` | ✅ | Wallet (Safe) address |
|
|
162
|
+
| `--to` | ✅ | Recipient address |
|
|
163
|
+
| `--value-eth` | ✅ | Amount in ETH |
|
|
164
|
+
| `--signer-type` | ✅ | `passkey` or `eoa` |
|
|
165
|
+
| `--passkey-x` | passkey wallets | Passkey public key X |
|
|
166
|
+
| `--passkey-y` | passkey wallets | Passkey public key Y |
|
|
167
|
+
| `--user-signer` | eoa wallets | User's EOA address |
|
|
168
|
+
| `--recovery` | first tx | Recovery signer address |
|
|
169
|
+
| `--nonce` | ❌ | Force nonce (for replacements) |
|
|
170
|
+
| `--purpose` | ❌ | Human-readable label |
|
|
171
|
+
|
|
172
|
+
### Environment Variables
|
|
173
|
+
|
|
174
|
+
| Var | Required | Description |
|
|
175
|
+
|-----|----------|-------------|
|
|
176
|
+
| `NODPAY_AGENT_KEY` | ✅ | Agent signing key |
|
|
177
|
+
| `RPC_URL` | ❌ | RPC endpoint (default: Sepolia) |
|
|
178
|
+
| `CHAIN_ID` | ❌ | Chain ID (default: 11155111) |
|
|
179
|
+
| `PIMLICO_API_KEY` | ❌ | Own bundler key (optional, NodPay proxy is default) |
|
|
180
|
+
|
|
181
|
+
### Supported Chains
|
|
182
|
+
|
|
183
|
+
`sepolia`, `ethereum`, `base`, `base_sepolia`, `arbitrum`, `optimism`, `polygon`
|
|
184
|
+
|
|
185
|
+
Set `RPC_URL` and `CHAIN_ID` for non-Sepolia chains. See `references/networks.json` for chain configs.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Transaction Patterns
|
|
190
|
+
|
|
191
|
+
**Sequential**: Just call propose multiple times. Nonces auto-increment.
|
|
192
|
+
|
|
193
|
+
**Replace**: Propose with `--nonce N` (same nonce as the pending tx). User picks which to approve.
|
|
194
|
+
|
|
195
|
+
**Cascade**: Rejecting tx #N auto-invalidates all tx with nonce > N. This is irreversible.
|
|
196
|
+
|
|
197
|
+
⚠️ **Never propose a new nonce then reject the old one** — the cascade will destroy your new tx too.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Reconnect (Wallet Recovery)
|
|
202
|
+
|
|
203
|
+
If the user cleared their browser data, the wallet still exists on-chain. Build a reconnect link:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
https://nodpay.ai/?agent=YOUR_AGENT_ADDRESS&safe=WALLET_ADDRESS&recovery=RECOVERY_SIGNER&x=PASSKEY_X&y=PASSKEY_Y
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
User opens → verifies passkey → wallet restored. No on-chain action needed.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Security Model
|
|
214
|
+
|
|
215
|
+
| Owner | Holder | Can do |
|
|
216
|
+
|-------|--------|--------|
|
|
217
|
+
| Agent EOA | You | Propose only |
|
|
218
|
+
| Passkey | User's device | Approve or reject |
|
|
219
|
+
| Recovery | User's 12-word phrase | Backup access |
|
|
220
|
+
|
|
221
|
+
- 2-of-3 threshold — you cannot execute alone
|
|
222
|
+
- Passkey X/Y are public key material, safe to store
|
|
223
|
+
- No private keys stored on NodPay's server
|
|
224
|
+
- Funds are safe on-chain even if NodPay goes offline
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Common Requests
|
|
229
|
+
|
|
230
|
+
| User says | Action |
|
|
231
|
+
|-----------|--------|
|
|
232
|
+
| "create a wallet" | Send wallet creation link |
|
|
233
|
+
| "send 0.1 ETH to 0x..." | Propose transaction |
|
|
234
|
+
| "balance" | RPC `eth_getBalance` on Safe address |
|
|
235
|
+
| "pending?" | `GET /api/txs?safe=...` |
|
|
236
|
+
| "wallet disappeared" | Send reconnect link |
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nodpay
|
|
3
|
+
description: Propose on-chain payments from a shared wallet. Use when user asks to send crypto, make a payment, or create a shared wallet.
|
|
4
|
+
metadata:
|
|
5
|
+
{ "openclaw": { "requires": { "env": ["NODPAY_AGENT_KEY"] }, "primaryEnv": "NODPAY_AGENT_KEY" } }
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# NodPay — Agent Wallet
|
|
9
|
+
|
|
10
|
+
> Two minds, one wallet.
|
|
11
|
+
|
|
12
|
+
NodPay lets you (an AI agent) propose on-chain payments from a shared wallet. Your human co-signs with one tap. You cannot spend without their approval.
|
|
13
|
+
|
|
14
|
+
**When to use**: User asks you to send crypto, make a payment, or create a shared wallet.
|
|
15
|
+
|
|
16
|
+
## How It Works
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
You (agent) User (human)
|
|
20
|
+
│ │
|
|
21
|
+
├─ generate key ─────────────────────►│ (send setup link)
|
|
22
|
+
│ ├─ create wallet on NodPay
|
|
23
|
+
│◄── wallet info ─────────────────────┤ (user sends back wallet details)
|
|
24
|
+
│ │
|
|
25
|
+
├─ propose tx ───► NodPay op-store │
|
|
26
|
+
│ ├── approve link ──►│
|
|
27
|
+
│ │◄── sign + submit ─┤
|
|
28
|
+
│ │
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The wallet is a **Safe 2-of-3 multisig**. You hold 1 key, the user holds 2 (passkey + recovery). You cannot move funds alone.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Setup (one-time)
|
|
36
|
+
|
|
37
|
+
### 1. Generate an agent key
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node -e "const w=require('ethers').Wallet.createRandom();console.log('Address:',w.address,'\nKey:',w.privateKey)"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Store the private key as `NODPAY_AGENT_KEY` in your environment. The address is your public agent identity.
|
|
44
|
+
|
|
45
|
+
### 2. Send the user a wallet link
|
|
46
|
+
|
|
47
|
+
Replace `YOUR_AGENT_ADDRESS` with your EOA address and send:
|
|
48
|
+
|
|
49
|
+
> I've set up a shared wallet for us. Open this link to create your wallet — takes 30 seconds:
|
|
50
|
+
>
|
|
51
|
+
> https://nodpay.ai/?agent=YOUR_AGENT_ADDRESS
|
|
52
|
+
>
|
|
53
|
+
> Just follow the steps: create a passkey, save your recovery phrase, done.
|
|
54
|
+
|
|
55
|
+
**Use the URL above verbatim.** Do not search for NodPay or guess the URL — `nodpay.ai` is the only correct domain.
|
|
56
|
+
|
|
57
|
+
User opens → creates passkey → saves recovery phrase → gets a wallet. They will copy wallet info back to you:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
NodPay Wallet (Safe): 0x...
|
|
61
|
+
Passkey X: 0x...
|
|
62
|
+
Passkey Y: 0x...
|
|
63
|
+
Agent: 0x...
|
|
64
|
+
Recovery Signer: 0x...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Store all fields** — you need them for proposing transactions.
|
|
68
|
+
|
|
69
|
+
#### Wallet file management
|
|
70
|
+
|
|
71
|
+
Store wallet info in `.nodpay/wallets/` in your workspace root (separate from skill code):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
.nodpay/wallets/
|
|
75
|
+
0xAbC...123.json # one file per wallet, named by Safe address
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Each wallet file:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"safe": "0x...",
|
|
83
|
+
"agentSigner": "0x...",
|
|
84
|
+
"signerType": "passkey",
|
|
85
|
+
"passkeyX": "0x...",
|
|
86
|
+
"passkeyY": "0x...",
|
|
87
|
+
"recovery": "0x...",
|
|
88
|
+
"chain": "sepolia",
|
|
89
|
+
"createdAt": "2025-01-01"
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`agentSigner` is your agent's EOA address (derived from `NODPAY_AGENT_KEY`).
|
|
94
|
+
|
|
95
|
+
For EOA wallets, replace passkey fields with `"userSigner": "0x..."`.
|
|
96
|
+
|
|
97
|
+
One agent key serves all wallets — multi-wallet is handled user-side (different passkeys/recovery keys → different Safe addresses, same agent).
|
|
98
|
+
|
|
99
|
+
**⚠️ Verify the Agent address matches yours.** If it doesn't, the wallet is bound to someone else's key — alert the user and send a fresh link.
|
|
100
|
+
|
|
101
|
+
**Multiple wallets**: A user may have multiple wallets (different devices, different passkeys). Before proposing, confirm which wallet to use — especially if you manage more than one.
|
|
102
|
+
|
|
103
|
+
### Propose a transaction
|
|
104
|
+
|
|
105
|
+
First time only — install the propose script:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
git clone https://github.com/xhyumiracle/nodpay.git
|
|
109
|
+
cd nodpay/skill && npm install
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Then propose:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cd nodpay/skill && \
|
|
116
|
+
NODPAY_AGENT_KEY=0x... \
|
|
117
|
+
node scripts/propose.mjs \
|
|
118
|
+
--safe <WALLET_ADDRESS> \
|
|
119
|
+
--to <RECIPIENT> \
|
|
120
|
+
--value-eth <AMOUNT> \
|
|
121
|
+
--passkey-x <PASSKEY_X> \
|
|
122
|
+
--passkey-y <PASSKEY_Y> \
|
|
123
|
+
--recovery <RECOVERY_SIGNER> \
|
|
124
|
+
--signer-type passkey
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The script outputs JSON with an `approveUrl`. Send it to the user:
|
|
128
|
+
|
|
129
|
+
> 💰 Payment: 0.01 ETH → 0xRecipient...
|
|
130
|
+
> 👉 Approve: https://nodpay.ai/approve?safeOpHash=0x...
|
|
131
|
+
|
|
132
|
+
**First transaction deploys the wallet on-chain.** Pass `--passkey-x`, `--passkey-y`, and `--recovery` for the first tx. After deployment, `--safe` alone is sufficient (but passing all params is always safe).
|
|
133
|
+
|
|
134
|
+
### Check balance
|
|
135
|
+
|
|
136
|
+
Use the RPC URL for the wallet's chain (see `references/networks.json`):
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
curl -s -X POST <RPC_URL> \
|
|
140
|
+
-H "Content-Type: application/json" \
|
|
141
|
+
-d '{"jsonrpc":"2.0","method":"eth_getBalance","params":["<WALLET_ADDRESS>","latest"],"id":1}'
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If balance is 0, remind the user to deposit before proposing.
|
|
145
|
+
|
|
146
|
+
### Check pending transactions
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
curl https://nodpay.ai/api/txs?safe=<WALLET_ADDRESS>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Always check before proposing — this tells you the current nonce, pending ops, and wallet status.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Script Reference
|
|
157
|
+
|
|
158
|
+
### Flags
|
|
159
|
+
|
|
160
|
+
| Flag | Required | Description |
|
|
161
|
+
|------|----------|-------------|
|
|
162
|
+
| `--safe` | ✅ | Wallet (Safe) address |
|
|
163
|
+
| `--to` | ✅ | Recipient address |
|
|
164
|
+
| `--value-eth` | ✅ | Amount in ETH |
|
|
165
|
+
| `--signer-type` | ✅ | `passkey` or `eoa` |
|
|
166
|
+
| `--passkey-x` | passkey wallets | Passkey public key X |
|
|
167
|
+
| `--passkey-y` | passkey wallets | Passkey public key Y |
|
|
168
|
+
| `--user-signer` | eoa wallets | User's EOA address |
|
|
169
|
+
| `--recovery` | first tx | Recovery signer address |
|
|
170
|
+
| `--nonce` | optional | Force nonce (for replacements) |
|
|
171
|
+
| `--purpose` | optional | Human-readable label |
|
|
172
|
+
|
|
173
|
+
### Environment
|
|
174
|
+
|
|
175
|
+
Only one env var is required:
|
|
176
|
+
|
|
177
|
+
| Var | Description |
|
|
178
|
+
|-----|-------------|
|
|
179
|
+
| `NODPAY_AGENT_KEY` | Agent signing key (required) |
|
|
180
|
+
|
|
181
|
+
Chain config (RPC, bundler, explorer) is auto-resolved from `references/networks.json`. No need to set `RPC_URL`, `CHAIN_ID`, or bundler keys.
|
|
182
|
+
|
|
183
|
+
### Supported Chains
|
|
184
|
+
|
|
185
|
+
`sepolia`, `ethereum`, `base`, `base_sepolia`, `arbitrum`, `optimism`, `polygon`
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Transaction Patterns
|
|
190
|
+
|
|
191
|
+
**Sequential**: Just call propose multiple times. Nonces auto-increment (script handles this).
|
|
192
|
+
|
|
193
|
+
**Replace**: To replace a pending tx, propose with `--nonce N` where N is the nonce of the tx you want to replace. Check pending nonces via `GET /api/txs?safe=<ADDRESS>` — each tx in the response includes its `nonce`.
|
|
194
|
+
|
|
195
|
+
**Cascade**: Rejecting tx at nonce N auto-invalidates all tx with nonce > N. This is irreversible.
|
|
196
|
+
|
|
197
|
+
⚠️ **Never propose a new nonce then reject an older one** — the cascade will destroy your new tx too.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Reconnect (Wallet Recovery)
|
|
202
|
+
|
|
203
|
+
If the user cleared their browser data, the wallet still exists on-chain. Build a reconnect link:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
https://nodpay.ai/?agent=YOUR_AGENT_ADDRESS&safe=WALLET_ADDRESS&recovery=RECOVERY_SIGNER&x=PASSKEY_X&y=PASSKEY_Y
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
User opens → verifies passkey → wallet restored. No on-chain action needed.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Security Model
|
|
214
|
+
|
|
215
|
+
| Owner | Holder | Can do |
|
|
216
|
+
|-------|--------|--------|
|
|
217
|
+
| Agent EOA | You | Propose only |
|
|
218
|
+
| Passkey | User's device | Approve or reject |
|
|
219
|
+
| Recovery | User's 12-word phrase | Backup access |
|
|
220
|
+
|
|
221
|
+
- 2-of-3 threshold — you cannot execute alone
|
|
222
|
+
- Passkey X/Y are public key material, safe to store
|
|
223
|
+
- No private keys stored on NodPay's server
|
|
224
|
+
- Funds are safe on-chain even if NodPay goes offline
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Common Requests
|
|
229
|
+
|
|
230
|
+
| User says | Action |
|
|
231
|
+
|-----------|--------|
|
|
232
|
+
| "create a wallet" | Send `https://nodpay.ai/?agent=YOUR_ADDRESS` |
|
|
233
|
+
| "send 0.1 ETH to 0x..." | Propose transaction |
|
|
234
|
+
| "balance" | RPC `eth_getBalance` on Safe address |
|
|
235
|
+
| "pending?" | `GET /api/txs?safe=...` |
|
|
236
|
+
| "wallet disappeared" | Send reconnect link |
|
package/bin/nodpay.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const command = process.argv[2];
|
|
4
|
+
|
|
5
|
+
if (command === 'propose') {
|
|
6
|
+
// Forward all args after 'propose' to the propose script
|
|
7
|
+
const scriptPath = new URL('../scripts/propose.mjs', import.meta.url).pathname;
|
|
8
|
+
process.argv = [process.argv[0], scriptPath, ...process.argv.slice(3)];
|
|
9
|
+
await import(scriptPath);
|
|
10
|
+
} else if (command === 'version' || command === '--version' || command === '-v') {
|
|
11
|
+
const { readFileSync } = await import('fs');
|
|
12
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
|
+
console.log(`nodpay v${pkg.version}`);
|
|
14
|
+
} else {
|
|
15
|
+
console.log(`Usage: nodpay <command>
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
propose Propose a transaction for human approval
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
nodpay propose --safe 0x... --to 0x... --value-eth 0.01 --signer-type passkey
|
|
22
|
+
|
|
23
|
+
Docs: https://nodpay.ai/skill.md`);
|
|
24
|
+
process.exit(command ? 1 : 0);
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nodpay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "NodPay CLI — propose on-chain payments from agent-human shared wallets",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nodpay": "./bin/nodpay.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"scripts/",
|
|
12
|
+
"references/",
|
|
13
|
+
"SKILL.md",
|
|
14
|
+
"AGENT-NOTES.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": ["crypto", "wallet", "safe", "multisig", "agent", "erc-4337", "payment"],
|
|
17
|
+
"author": "xhyumiracle",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@nodpay/core": "^0.1.0",
|
|
21
|
+
"@safe-global/relay-kit": "^4.1.1",
|
|
22
|
+
"ethers": "^6.16.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mainnet": {
|
|
3
|
+
"ethereum": {
|
|
4
|
+
"name": "Ethereum",
|
|
5
|
+
"chainId": 1,
|
|
6
|
+
"rpcUrl": "https://ethereum-rpc.publicnode.com",
|
|
7
|
+
"txServiceUrl": "https://safe-transaction-mainnet.safe.global",
|
|
8
|
+
"safePrefix": "eth",
|
|
9
|
+
"explorerUrl": "https://etherscan.io",
|
|
10
|
+
"explorerTxPath": "/tx/",
|
|
11
|
+
"explorerAddrPath": "/address/",
|
|
12
|
+
"nativeCurrency": "ETH",
|
|
13
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/1/rpc?apikey={PIMLICO_API_KEY}"
|
|
14
|
+
},
|
|
15
|
+
"base": {
|
|
16
|
+
"name": "Base",
|
|
17
|
+
"chainId": 8453,
|
|
18
|
+
"rpcUrl": "https://base-rpc.publicnode.com",
|
|
19
|
+
"txServiceUrl": "https://safe-transaction-base.safe.global",
|
|
20
|
+
"safePrefix": "base",
|
|
21
|
+
"explorerUrl": "https://basescan.org",
|
|
22
|
+
"explorerTxPath": "/tx/",
|
|
23
|
+
"explorerAddrPath": "/address/",
|
|
24
|
+
"nativeCurrency": "ETH",
|
|
25
|
+
"gasSponsored": true,
|
|
26
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/8453/rpc?apikey={PIMLICO_API_KEY}"
|
|
27
|
+
},
|
|
28
|
+
"arbitrum": {
|
|
29
|
+
"name": "Arbitrum",
|
|
30
|
+
"chainId": 42161,
|
|
31
|
+
"rpcUrl": "https://arbitrum-one-rpc.publicnode.com",
|
|
32
|
+
"txServiceUrl": "https://safe-transaction-arbitrum.safe.global",
|
|
33
|
+
"safePrefix": "arb1",
|
|
34
|
+
"explorerUrl": "https://arbiscan.io",
|
|
35
|
+
"explorerTxPath": "/tx/",
|
|
36
|
+
"explorerAddrPath": "/address/",
|
|
37
|
+
"nativeCurrency": "ETH",
|
|
38
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/42161/rpc?apikey={PIMLICO_API_KEY}"
|
|
39
|
+
},
|
|
40
|
+
"polygon": {
|
|
41
|
+
"name": "Polygon",
|
|
42
|
+
"chainId": 137,
|
|
43
|
+
"rpcUrl": "https://polygon-bor-rpc.publicnode.com",
|
|
44
|
+
"txServiceUrl": "https://safe-transaction-polygon.safe.global",
|
|
45
|
+
"safePrefix": "matic",
|
|
46
|
+
"explorerUrl": "https://polygonscan.com",
|
|
47
|
+
"explorerTxPath": "/tx/",
|
|
48
|
+
"explorerAddrPath": "/address/",
|
|
49
|
+
"nativeCurrency": "MATIC",
|
|
50
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/137/rpc?apikey={PIMLICO_API_KEY}"
|
|
51
|
+
},
|
|
52
|
+
"optimism": {
|
|
53
|
+
"name": "Optimism",
|
|
54
|
+
"chainId": 10,
|
|
55
|
+
"rpcUrl": "https://optimism-rpc.publicnode.com",
|
|
56
|
+
"txServiceUrl": "https://safe-transaction-optimism.safe.global",
|
|
57
|
+
"safePrefix": "oeth",
|
|
58
|
+
"explorerUrl": "https://optimistic.etherscan.io",
|
|
59
|
+
"explorerTxPath": "/tx/",
|
|
60
|
+
"explorerAddrPath": "/address/",
|
|
61
|
+
"nativeCurrency": "ETH",
|
|
62
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/10/rpc?apikey={PIMLICO_API_KEY}"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"testnet": {
|
|
66
|
+
"sepolia": {
|
|
67
|
+
"name": "Sepolia (Ethereum)",
|
|
68
|
+
"chainId": 11155111,
|
|
69
|
+
"rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com",
|
|
70
|
+
"txServiceUrl": "https://safe-transaction-sepolia.safe.global",
|
|
71
|
+
"safePrefix": "sep",
|
|
72
|
+
"explorerUrl": "https://sepolia.etherscan.io",
|
|
73
|
+
"explorerTxPath": "/tx/",
|
|
74
|
+
"explorerAddrPath": "/address/",
|
|
75
|
+
"nativeCurrency": "ETH",
|
|
76
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/11155111/rpc?apikey={PIMLICO_API_KEY}"
|
|
77
|
+
},
|
|
78
|
+
"base_sepolia": {
|
|
79
|
+
"name": "Base Sepolia",
|
|
80
|
+
"chainId": 84532,
|
|
81
|
+
"rpcUrl": "https://base-sepolia-rpc.publicnode.com",
|
|
82
|
+
"txServiceUrl": "https://safe-transaction-base-sepolia.safe.global",
|
|
83
|
+
"safePrefix": "basesep",
|
|
84
|
+
"explorerUrl": "https://sepolia.basescan.org",
|
|
85
|
+
"explorerTxPath": "/tx/",
|
|
86
|
+
"explorerAddrPath": "/address/",
|
|
87
|
+
"nativeCurrency": "ETH",
|
|
88
|
+
"bundlerUrlTemplate": "https://api.pimlico.io/v2/84532/rpc?apikey={PIMLICO_API_KEY}"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Predict a Safe address for a passkey-based wallet (counterfactual).
|
|
4
|
+
*
|
|
5
|
+
* Uses Safe4337Pack with passkey signer support to ensure the Safe setup
|
|
6
|
+
* includes SharedSigner.configure() for passkey coordinates.
|
|
7
|
+
*
|
|
8
|
+
* Env vars:
|
|
9
|
+
* NODPAY_AGENT_KEY - Agent signer private key
|
|
10
|
+
* RPC_URL - RPC endpoint
|
|
11
|
+
* CHAIN_ID - Chain ID (default: 11155111)
|
|
12
|
+
* PIMLICO_API_KEY - Pimlico bundler API key
|
|
13
|
+
*
|
|
14
|
+
* Args:
|
|
15
|
+
* --raw-id <base64> - Passkey rawId (base64 encoded)
|
|
16
|
+
* --x <hex> - Passkey public key x coordinate
|
|
17
|
+
* --y <hex> - Passkey public key y coordinate
|
|
18
|
+
* --salt <nonce> - Salt nonce (optional, default: random)
|
|
19
|
+
* --chain-id <number> - Override chain ID
|
|
20
|
+
*
|
|
21
|
+
* Output: JSON { predictedAddress, owners, threshold, chainId, salt, deployed, passkeyCoordinates }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Safe4337Pack } from '@safe-global/relay-kit';
|
|
25
|
+
import Safe, { getMultiSendContract, encodeMultiSendData, SafeProvider } from '@safe-global/protocol-kit';
|
|
26
|
+
import { OperationType } from '@safe-global/types-kit';
|
|
27
|
+
import { ethers } from 'ethers';
|
|
28
|
+
|
|
29
|
+
const SHARED_SIGNER_ADDRESS = '0x94a4F6affBd8975951142c3999aEAB7ecee555c2';
|
|
30
|
+
|
|
31
|
+
// Default P-256 verifier (FCLP256Verifier) on major chains
|
|
32
|
+
// From safe-modules-deployments v0.2.1
|
|
33
|
+
const FCLP256_VERIFIERS = {
|
|
34
|
+
'11155111': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Sepolia
|
|
35
|
+
'1': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Mainnet
|
|
36
|
+
'8453': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Base
|
|
37
|
+
'84532': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Base Sepolia
|
|
38
|
+
'42161': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Arbitrum
|
|
39
|
+
'10': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Optimism
|
|
40
|
+
'137': '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765', // Polygon
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const RPC_URL = process.env.RPC_URL || 'https://ethereum-sepolia-rpc.publicnode.com';
|
|
44
|
+
const NODPAY_AGENT_KEY = process.env.NODPAY_AGENT_KEY;
|
|
45
|
+
const PIMLICO_API_KEY = process.env.PIMLICO_API_KEY;
|
|
46
|
+
|
|
47
|
+
if (!NODPAY_AGENT_KEY) {
|
|
48
|
+
console.error(JSON.stringify({ error: 'Missing NODPAY_AGENT_KEY env var' }));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
if (!PIMLICO_API_KEY) {
|
|
52
|
+
console.error(JSON.stringify({ error: 'Missing PIMLICO_API_KEY env var' }));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const agentWallet = new ethers.Wallet(NODPAY_AGENT_KEY);
|
|
57
|
+
const DEFAULT_AGENT_ADDRESS = agentWallet.address;
|
|
58
|
+
|
|
59
|
+
const args = process.argv.slice(2);
|
|
60
|
+
function getArg(name) {
|
|
61
|
+
const idx = args.indexOf(name);
|
|
62
|
+
return idx !== -1 ? args[idx + 1] : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rawId = getArg('--raw-id');
|
|
66
|
+
const x = getArg('--x');
|
|
67
|
+
const y = getArg('--y');
|
|
68
|
+
const CHAIN_ID = parseInt(getArg('--chain-id') || process.env.CHAIN_ID || '11155111', 10);
|
|
69
|
+
const salt = getArg('--salt') || Math.floor(Math.random() * 1_000_000_000).toString();
|
|
70
|
+
// Agent address can be overridden via --agent flag (for multi-agent support)
|
|
71
|
+
const AGENT_ADDRESS = getArg('--agent') || DEFAULT_AGENT_ADDRESS;
|
|
72
|
+
const RECOVERY_ADDRESS = getArg('--recovery'); // Optional 3rd owner for 2-of-3
|
|
73
|
+
|
|
74
|
+
if (!x || !y) {
|
|
75
|
+
console.error(JSON.stringify({ error: 'Missing --x <hex> and --y <hex> passkey coordinates' }));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const owners = RECOVERY_ADDRESS
|
|
81
|
+
? [AGENT_ADDRESS, SHARED_SIGNER_ADDRESS, RECOVERY_ADDRESS]
|
|
82
|
+
: [AGENT_ADDRESS, SHARED_SIGNER_ADDRESS];
|
|
83
|
+
const threshold = 2;
|
|
84
|
+
const bundlerUrl = `https://api.pimlico.io/v2/${CHAIN_ID}/rpc?apikey=${PIMLICO_API_KEY}`;
|
|
85
|
+
const verifier = FCLP256_VERIFIERS[String(CHAIN_ID)] || FCLP256_VERIFIERS['11155111'];
|
|
86
|
+
|
|
87
|
+
// Encode SharedSigner.configure() — stores passkey coordinates in Safe's storage
|
|
88
|
+
const sharedSignerIface = new ethers.Interface([
|
|
89
|
+
'function configure((uint256 x, uint256 y, uint176 verifiers) signer)'
|
|
90
|
+
]);
|
|
91
|
+
const configureData = sharedSignerIface.encodeFunctionData('configure', [{
|
|
92
|
+
x: BigInt(x),
|
|
93
|
+
y: BigInt(y),
|
|
94
|
+
verifiers: BigInt(verifier)
|
|
95
|
+
}]);
|
|
96
|
+
|
|
97
|
+
// We need Safe4337Pack to use our custom setup that includes:
|
|
98
|
+
// 1. enableModules([Safe4337Module]) — done by Safe4337Pack
|
|
99
|
+
// 2. SharedSigner.configure({x, y, verifiers}) — we need to inject this
|
|
100
|
+
//
|
|
101
|
+
// Strategy: Initialize Safe4337Pack normally, then monkey-patch protocolKit
|
|
102
|
+
// to include our configure call in the predicted Safe config.
|
|
103
|
+
|
|
104
|
+
// First, init Safe4337Pack to get module addresses and standard config
|
|
105
|
+
const safe4337Pack = await Safe4337Pack.init({
|
|
106
|
+
provider: RPC_URL,
|
|
107
|
+
signer: NODPAY_AGENT_KEY,
|
|
108
|
+
bundlerUrl,
|
|
109
|
+
options: {
|
|
110
|
+
owners,
|
|
111
|
+
threshold,
|
|
112
|
+
saltNonce: salt,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Get the address WITHOUT passkey config (this is what we had before — wrong)
|
|
117
|
+
const addressWithoutPasskey = await safe4337Pack.protocolKit.getAddress();
|
|
118
|
+
|
|
119
|
+
// Now create a new Safe4337Pack with passkey signer to get the CORRECT address
|
|
120
|
+
// We pass a fake passkey signer object that Safe4337Pack recognizes
|
|
121
|
+
const passkeySignerObj = {
|
|
122
|
+
rawId: rawId || 'passkey-' + salt,
|
|
123
|
+
coordinates: { x, y },
|
|
124
|
+
customVerifierAddress: verifier,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const ownersForPasskeyPack = RECOVERY_ADDRESS
|
|
128
|
+
? [AGENT_ADDRESS, RECOVERY_ADDRESS] // SharedSigner will be auto-added by SDK
|
|
129
|
+
: [AGENT_ADDRESS]; // SharedSigner will be auto-added by SDK
|
|
130
|
+
const safe4337PackWithPasskey = await Safe4337Pack.init({
|
|
131
|
+
provider: RPC_URL,
|
|
132
|
+
signer: passkeySignerObj,
|
|
133
|
+
bundlerUrl,
|
|
134
|
+
options: {
|
|
135
|
+
owners: ownersForPasskeyPack,
|
|
136
|
+
threshold,
|
|
137
|
+
saltNonce: salt,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const predictedAddress = await safe4337PackWithPasskey.protocolKit.getAddress();
|
|
142
|
+
|
|
143
|
+
// Check if already deployed
|
|
144
|
+
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
|
145
|
+
const code = await provider.getCode(predictedAddress);
|
|
146
|
+
const deployed = code !== '0x';
|
|
147
|
+
|
|
148
|
+
const result = {
|
|
149
|
+
predictedAddress,
|
|
150
|
+
owners,
|
|
151
|
+
threshold,
|
|
152
|
+
chainId: CHAIN_ID,
|
|
153
|
+
salt,
|
|
154
|
+
deployed,
|
|
155
|
+
sharedSigner: SHARED_SIGNER_ADDRESS,
|
|
156
|
+
verifier,
|
|
157
|
+
passkeyCoordinates: { x, y },
|
|
158
|
+
rawId: rawId || null,
|
|
159
|
+
// For debugging: show old address without passkey config
|
|
160
|
+
_addressWithoutPasskeyConfig: addressWithoutPasskey,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
console.log(JSON.stringify(result, null, 2));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(JSON.stringify({ error: error.message || String(error) }));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Create and partially sign a Safe UserOperation via ERC-4337.
|
|
4
|
+
* Alternative to propose-tx.mjs for 4337/passkey users.
|
|
5
|
+
*
|
|
6
|
+
* The agent signs first (1 of 2). The serialized SafeOperation is
|
|
7
|
+
* output so the web app can have the user co-sign and submit.
|
|
8
|
+
*
|
|
9
|
+
* Env vars:
|
|
10
|
+
* NODPAY_AGENT_KEY - Agent signer private key
|
|
11
|
+
* SAFE_ADDRESS - Deployed Safe address (can be overridden with --safe)
|
|
12
|
+
* RPC_URL - RPC endpoint
|
|
13
|
+
* CHAIN_ID - Chain ID (default: 11155111)
|
|
14
|
+
*
|
|
15
|
+
* Args:
|
|
16
|
+
* --to <address> - Recipient address
|
|
17
|
+
* --value-eth <amount> - Value in ETH (default: 0)
|
|
18
|
+
* --purpose <text> - Human-readable purpose
|
|
19
|
+
* --safe <address> - Override SAFE_ADDRESS
|
|
20
|
+
* --counterfactual - Safe not yet deployed; include deployment in UserOp
|
|
21
|
+
* --user-signer <address> - User's signer address (required for counterfactual)
|
|
22
|
+
* --salt <nonce> - Salt nonce (required for counterfactual)
|
|
23
|
+
* --reuse-gas-from <shortHash> - Reuse gas values from a previous op (shortHash prefix of safeOpHash)
|
|
24
|
+
* --nonce <n> - Override nonce
|
|
25
|
+
*
|
|
26
|
+
* Output: JSON with userOpHash, safeTxHash, safeOperationJson, etc.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Safe4337Pack } from '@safe-global/relay-kit';
|
|
30
|
+
import { ethers } from 'ethers';
|
|
31
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
32
|
+
import { join, dirname } from 'path';
|
|
33
|
+
import { fileURLToPath } from 'url';
|
|
34
|
+
import { computeUserOpHash, ENTRYPOINT } from '@nodpay/core';
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const PENDING_DIR = join(__dirname, '..', '.pending-txs');
|
|
38
|
+
mkdirSync(PENDING_DIR, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const RPC_URL = process.env.RPC_URL || 'https://ethereum-sepolia-rpc.publicnode.com';
|
|
41
|
+
const CHAIN_ID = process.env.CHAIN_ID || '11155111';
|
|
42
|
+
const ENTRYPOINT_ADDRESS = ENTRYPOINT;
|
|
43
|
+
const NODPAY_AGENT_KEY = process.env.NODPAY_AGENT_KEY;
|
|
44
|
+
const DEFAULT_SAFE = process.env.SAFE_ADDRESS;
|
|
45
|
+
|
|
46
|
+
// Safe4337Pack.init requires a bundlerUrl — it calls eth_chainId during init.
|
|
47
|
+
// Use the NodPay server's bundler proxy so agents don't need their own bundler key.
|
|
48
|
+
// Fallback to pimlico if PIMLICO_API_KEY is set (for local dev/testing).
|
|
49
|
+
const PIMLICO_API_KEY = process.env.PIMLICO_API_KEY;
|
|
50
|
+
const isProd = (process.env.NODE_ENV || 'production') === 'production';
|
|
51
|
+
const opStoreBase = process.env.OP_STORE_URL || (isProd ? 'https://nodpay.ai/api' : 'http://localhost:8766');
|
|
52
|
+
const BUNDLER_URL = PIMLICO_API_KEY
|
|
53
|
+
? `https://api.pimlico.io/v2/${CHAIN_ID}/rpc?apikey=${PIMLICO_API_KEY}`
|
|
54
|
+
: `${opStoreBase}/bundler/${CHAIN_ID}`;
|
|
55
|
+
|
|
56
|
+
if (!NODPAY_AGENT_KEY) {
|
|
57
|
+
console.error(JSON.stringify({ error: 'Missing NODPAY_AGENT_KEY env var' }));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const agentWallet = new ethers.Wallet(NODPAY_AGENT_KEY);
|
|
62
|
+
const AGENT_ADDRESS = agentWallet.address;
|
|
63
|
+
|
|
64
|
+
const args = process.argv.slice(2);
|
|
65
|
+
function getArg(name) {
|
|
66
|
+
const idx = args.indexOf(name);
|
|
67
|
+
return idx !== -1 ? args[idx + 1] : undefined;
|
|
68
|
+
}
|
|
69
|
+
function hasFlag(name) {
|
|
70
|
+
return args.includes(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const to = getArg('--to');
|
|
74
|
+
const valueEth = getArg('--value-eth') || getArg('--value') || '0';
|
|
75
|
+
const purpose = getArg('--purpose') || 'Unspecified';
|
|
76
|
+
const safeOverride = getArg('--safe');
|
|
77
|
+
let isCounterfactual = hasFlag('--counterfactual');
|
|
78
|
+
const userSigner = getArg('--user-signer');
|
|
79
|
+
const salt = getArg('--salt') || '1001';
|
|
80
|
+
|
|
81
|
+
// Passkey support
|
|
82
|
+
const passkeyX = getArg('--passkey-x');
|
|
83
|
+
const passkeyY = getArg('--passkey-y');
|
|
84
|
+
const passkeyRawId = getArg('--passkey-raw-id');
|
|
85
|
+
const passkeyVerifier = getArg('--passkey-verifier') || '0x445a0683e494ea0c5AF3E83c5159fBE47Cf9e765';
|
|
86
|
+
const recoveryAddress = getArg('--recovery');
|
|
87
|
+
const isPasskey = !!(passkeyX && passkeyY);
|
|
88
|
+
|
|
89
|
+
if (!to) {
|
|
90
|
+
console.error(JSON.stringify({ error: 'Missing --to <address>' }));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!ethers.isAddress(to)) {
|
|
95
|
+
console.error(JSON.stringify({ error: `Invalid recipient address: ${to}` }));
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SAFE_ADDRESS = safeOverride || DEFAULT_SAFE;
|
|
100
|
+
|
|
101
|
+
if (!isCounterfactual && !SAFE_ADDRESS) {
|
|
102
|
+
console.error(JSON.stringify({ error: 'Missing SAFE_ADDRESS. Use --safe <address> or set SAFE_ADDRESS env, or use --counterfactual.' }));
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Auto-detect counterfactual: if --safe given but not deployed, switch to counterfactual
|
|
107
|
+
if (!isCounterfactual && SAFE_ADDRESS) {
|
|
108
|
+
const _provider = new ethers.JsonRpcProvider(RPC_URL);
|
|
109
|
+
const _code = await _provider.getCode(SAFE_ADDRESS);
|
|
110
|
+
if (_code === '0x') {
|
|
111
|
+
isCounterfactual = true;
|
|
112
|
+
console.error(`[INFO] Safe ${SAFE_ADDRESS} not deployed, switching to counterfactual mode`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isCounterfactual && !userSigner && !isPasskey) {
|
|
117
|
+
console.error(JSON.stringify({ error: '--counterfactual requires --user-signer <address> (or use passkey mode)' }));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const value = ethers.parseEther(valueEth).toString();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fetch conservative gas values from chain RPC.
|
|
125
|
+
* Uses gasPrice × 3 as a buffer for fee volatility.
|
|
126
|
+
* Gas limits are hardcoded floors that comfortably cover P-256 FCL verification.
|
|
127
|
+
* Excess gas limits are NOT charged — actual fee = actual gas used × actual price.
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* Gas estimation from measured components — no bundler needed.
|
|
131
|
+
* See ARCHITECTURE.md Section 2. Excess limits are NOT charged on-chain.
|
|
132
|
+
*
|
|
133
|
+
* ┌─────────────────────────────────┬──────────┬────────────────────────────────┐
|
|
134
|
+
* │ Component │ Measured │ Source │
|
|
135
|
+
* ├─────────────────────────────────┼──────────┼────────────────────────────────┤
|
|
136
|
+
* │ Safe proxy CREATE2 + setup() │ 425,000 │ deploy(777k) - call(352k) │
|
|
137
|
+
* │ EntryPoint handleOps overhead │ 55,000 │ receipt - inner execution │
|
|
138
|
+
* │ Safe validateUserOp (non-P256) │ 45,000 │ signature decode + module call │
|
|
139
|
+
* │ Safe executeUserOp dispatch │ 30,000 │ module → execTransaction │
|
|
140
|
+
* │ ETH transfer (base cost) │ 21,000 │ EVM constant │
|
|
141
|
+
* │ P-256 via RIP-7212 precompile │ 27,000 │ estimateGas on all 7 chains │
|
|
142
|
+
* │ P-256 via FCL library fallback │ 400,000 │ observed range 200-400k │
|
|
143
|
+
* │ initCode validation overhead │ 50,000 │ factory call decode + verify │
|
|
144
|
+
* ├─────────────────────────────────┼──────────┼────────────────────────────────┤
|
|
145
|
+
* │ Safety multiplier │ 1.4x │ covers browser variance in │
|
|
146
|
+
* │ │ │ clientDataJSON length │
|
|
147
|
+
* └─────────────────────────────────┴──────────┴────────────────────────────────┘
|
|
148
|
+
*
|
|
149
|
+
* All supported chains have RIP-7212, but we size for FCL fallback
|
|
150
|
+
* in case new chains without precompile are added.
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Gas baselines from bundler simulation on Sepolia (with RIP-7212 precompile).
|
|
155
|
+
* These are the bundler's own estimates for successful txs — the most accurate reference.
|
|
156
|
+
*
|
|
157
|
+
* Scenario │ vGL │ cGL │ pvG │ P-256 method
|
|
158
|
+
* ──────────┼───────────┼─────────┼─────────┼──────────────
|
|
159
|
+
* deploy │ 2,653,312 │ 213,422 │ 194,498 │ precompile (27k)
|
|
160
|
+
* call │ 500,000 │ 218,222 │ 176,846 │ precompile (27k)
|
|
161
|
+
*
|
|
162
|
+
* For chains without RIP-7212, P-256 goes through FCL library (~400k).
|
|
163
|
+
* Delta = 400k - 27k = 373k added to vGL.
|
|
164
|
+
*
|
|
165
|
+
* Safety: 1.2x multiplier on baselines (covers browser clientDataJSON variance).
|
|
166
|
+
*/
|
|
167
|
+
const BUNDLER_BASELINE = {
|
|
168
|
+
deploy: { vgl: 2653312n, cgl: 213422n, pvg: 194498n },
|
|
169
|
+
call: { vgl: 500000n, cgl: 218222n, pvg: 176846n },
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const P256_PRECOMPILE_COST = 27000n;
|
|
173
|
+
const P256_FCL_COST = 400000n;
|
|
174
|
+
const P256_DELTA = P256_FCL_COST - P256_PRECOMPILE_COST; // 373k extra without precompile
|
|
175
|
+
|
|
176
|
+
// Chains with RIP-7212 precompile (verified via estimateGas on address 0x100)
|
|
177
|
+
const HAS_P256_PRECOMPILE = new Set([
|
|
178
|
+
'1', '8453', '42161', '10', '137', // mainnets
|
|
179
|
+
'11155111', '84532', // testnets
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const SAFETY = 12n; // 1.2x (divide by 10)
|
|
183
|
+
|
|
184
|
+
function estimateGas(isCounterfactual, chainId) {
|
|
185
|
+
const base = isCounterfactual ? BUNDLER_BASELINE.deploy : BUNDLER_BASELINE.call;
|
|
186
|
+
const hasPrecompile = HAS_P256_PRECOMPILE.has(String(chainId));
|
|
187
|
+
|
|
188
|
+
let vgl = base.vgl;
|
|
189
|
+
if (!hasPrecompile) vgl += P256_DELTA;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
verificationGasLimit: vgl * SAFETY / 10n,
|
|
193
|
+
callGasLimit: base.cgl * SAFETY / 10n,
|
|
194
|
+
preVerificationGas: base.pvg * SAFETY / 10n,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function getDefaultGasValues(isCounterfactual = false, chainId = CHAIN_ID) {
|
|
199
|
+
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
|
200
|
+
const feeData = await provider.getFeeData();
|
|
201
|
+
const gasPrice = feeData.gasPrice || ethers.parseUnits('20', 'gwei');
|
|
202
|
+
const FEE_MULTIPLIER = 3n; // covers gas price volatility between propose → approve
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...estimateGas(isCounterfactual, chainId),
|
|
206
|
+
maxFeePerGas: (gasPrice * FEE_MULTIPLIER).toString(),
|
|
207
|
+
maxPriorityFeePerGas: (ethers.parseUnits('2', 'gwei') * FEE_MULTIPLIER).toString(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
// Build init options for Safe4337Pack
|
|
213
|
+
// For passkey Safes, use passkey object as signer to get correct initCode
|
|
214
|
+
// (includes SharedSigner.configure() in setup). Agent signs manually afterward.
|
|
215
|
+
const initOptions = {
|
|
216
|
+
provider: RPC_URL,
|
|
217
|
+
bundlerUrl: BUNDLER_URL,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (isPasskey) {
|
|
221
|
+
// Passkey signer: Safe4337Pack auto-adds SharedSigner + configure() to setup
|
|
222
|
+
initOptions.signer = {
|
|
223
|
+
rawId: passkeyRawId || '0xdeadbeef',
|
|
224
|
+
coordinates: { x: passkeyX, y: passkeyY },
|
|
225
|
+
customVerifierAddress: passkeyVerifier,
|
|
226
|
+
};
|
|
227
|
+
if (isCounterfactual) {
|
|
228
|
+
const passkeyOwners = recoveryAddress
|
|
229
|
+
? [AGENT_ADDRESS, recoveryAddress] // SharedSigner auto-added by SDK
|
|
230
|
+
: [AGENT_ADDRESS]; // SharedSigner auto-added by SDK
|
|
231
|
+
initOptions.options = {
|
|
232
|
+
owners: passkeyOwners,
|
|
233
|
+
threshold: 2,
|
|
234
|
+
};
|
|
235
|
+
if (salt) initOptions.options.saltNonce = salt;
|
|
236
|
+
} else {
|
|
237
|
+
initOptions.options = { safeAddress: SAFE_ADDRESS };
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
// EOA signer: agent key as primary signer
|
|
241
|
+
initOptions.signer = NODPAY_AGENT_KEY;
|
|
242
|
+
if (isCounterfactual) {
|
|
243
|
+
// Canonical owner order: [userSigner, agent, recovery] — must match frontend
|
|
244
|
+
const eoaOwners = recoveryAddress
|
|
245
|
+
? [userSigner, AGENT_ADDRESS, recoveryAddress]
|
|
246
|
+
: [userSigner, AGENT_ADDRESS];
|
|
247
|
+
initOptions.options = {
|
|
248
|
+
owners: eoaOwners,
|
|
249
|
+
threshold: 2,
|
|
250
|
+
};
|
|
251
|
+
if (salt) initOptions.options.saltNonce = salt;
|
|
252
|
+
} else {
|
|
253
|
+
initOptions.options = { safeAddress: SAFE_ADDRESS };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const safe4337Pack = await Safe4337Pack.init(initOptions);
|
|
258
|
+
|
|
259
|
+
const safeAddress = await safe4337Pack.protocolKit.getAddress();
|
|
260
|
+
|
|
261
|
+
// Auto-detect deployment status: if Safe is already deployed, drop counterfactual
|
|
262
|
+
if (isCounterfactual) {
|
|
263
|
+
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
|
264
|
+
const code = await provider.getCode(safeAddress);
|
|
265
|
+
if (code !== '0x') {
|
|
266
|
+
isCounterfactual = false;
|
|
267
|
+
// Re-init without counterfactual options to avoid initCode
|
|
268
|
+
initOptions.options = { safeAddress };
|
|
269
|
+
if (isPasskey) {
|
|
270
|
+
// Keep passkey signer for correct module handling
|
|
271
|
+
} else {
|
|
272
|
+
// For EOA, just set safeAddress
|
|
273
|
+
}
|
|
274
|
+
// Note: Safe4337Pack will skip initCode for deployed Safes automatically
|
|
275
|
+
// since protocolKit detects deployment status
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Nonce + gas management for sequential proposals
|
|
280
|
+
const customNonceArg = getArg('--nonce');
|
|
281
|
+
const reuseGasFrom = getArg('--reuse-gas-from'); // shortHash of a previous op to copy gas values from
|
|
282
|
+
let txOptions = {};
|
|
283
|
+
const isProd = (process.env.NODE_ENV || 'production') === 'production';
|
|
284
|
+
const opStoreUrl = process.env.OP_STORE_URL || (isProd ? 'https://nodpay.ai/api' : 'http://localhost:8766');
|
|
285
|
+
const safeAddr = await safe4337Pack.protocolKit.getAddress();
|
|
286
|
+
|
|
287
|
+
// Determine nonce: on-chain nonce is the source of truth.
|
|
288
|
+
// For queued ops, find the highest pending nonce and increment.
|
|
289
|
+
if (customNonceArg !== undefined) {
|
|
290
|
+
txOptions.customNonce = BigInt(customNonceArg);
|
|
291
|
+
} else {
|
|
292
|
+
try {
|
|
293
|
+
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
|
294
|
+
const ep = new ethers.Contract('0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
|
|
295
|
+
['function getNonce(address,uint192) view returns (uint256)'], provider);
|
|
296
|
+
const onChainNonce = await ep.getNonce(safeAddr, 0);
|
|
297
|
+
|
|
298
|
+
// Check pending ops to find the highest queued nonce
|
|
299
|
+
const listRes = await fetch(`${opStoreUrl}/txs?safe=${safeAddr}&chain=${CHAIN_ID}`);
|
|
300
|
+
let nextNonce = onChainNonce;
|
|
301
|
+
if (listRes.ok) {
|
|
302
|
+
const listData = await listRes.json();
|
|
303
|
+
for (const op of (listData.txs || listData.ops || [])) {
|
|
304
|
+
const opNonce = BigInt(op.nonce ?? -1);
|
|
305
|
+
if (opNonce >= onChainNonce && opNonce >= nextNonce) {
|
|
306
|
+
nextNonce = opNonce + 1n;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Only set custom nonce if we need to skip ahead for queuing
|
|
311
|
+
if (nextNonce > onChainNonce) {
|
|
312
|
+
txOptions.customNonce = nextNonce;
|
|
313
|
+
}
|
|
314
|
+
// else: let SDK use on-chain nonce naturally
|
|
315
|
+
} catch (e) {
|
|
316
|
+
// Non-fatal: SDK will use its own nonce detection
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Resolve gas values: use hardcoded defaults (from RPC gas price) always.
|
|
321
|
+
// If --reuse-gas-from is provided and fetch succeeds, use those values instead
|
|
322
|
+
// (useful for re-proposing ops with identical gas parameters).
|
|
323
|
+
let gasValues = await getDefaultGasValues(isCounterfactual);
|
|
324
|
+
|
|
325
|
+
if (reuseGasFrom) {
|
|
326
|
+
try {
|
|
327
|
+
const refRes = await fetch(`${opStoreUrl}/tx/${reuseGasFrom}`);
|
|
328
|
+
if (refRes.ok) {
|
|
329
|
+
const refData = await refRes.json();
|
|
330
|
+
const uo = refData.data?.safeOperationJson?.userOperation;
|
|
331
|
+
if (uo) {
|
|
332
|
+
gasValues = {
|
|
333
|
+
callGasLimit: BigInt(uo.callGasLimit),
|
|
334
|
+
verificationGasLimit: BigInt(uo.verificationGasLimit),
|
|
335
|
+
preVerificationGas: BigInt(uo.preVerificationGas),
|
|
336
|
+
maxFeePerGas: BigInt(uo.maxFeePerGas),
|
|
337
|
+
maxPriorityFeePerGas: BigInt(uo.maxPriorityFeePerGas),
|
|
338
|
+
};
|
|
339
|
+
console.error(`[INFO] Reusing gas values from op ${reuseGasFrom}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch (e) {
|
|
343
|
+
// Non-fatal: fall through to defaults already set
|
|
344
|
+
console.error(`[INFO] Could not fetch gas from ${reuseGasFrom}, using defaults`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Monkey-patch getEstimateFee to always use our hardcoded gas values.
|
|
349
|
+
// This bypasses bundler simulation entirely — the dummy bundlerUrl is never called.
|
|
350
|
+
// Gas limits are set conservatively high; excess is NOT charged on-chain.
|
|
351
|
+
safe4337Pack.getEstimateFee = async ({ safeOperation }) => {
|
|
352
|
+
safeOperation.addEstimations(gasValues);
|
|
353
|
+
return safeOperation;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Create the transaction as a SafeOperation (UserOp wrapper)
|
|
357
|
+
const safeOperation = await safe4337Pack.createTransaction({
|
|
358
|
+
transactions: [{ to, value, data: '0x' }],
|
|
359
|
+
options: txOptions,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Agent signs (1 of 2 signatures)
|
|
363
|
+
// For passkey Safes, the pack's signer is the passkey (not agent key),
|
|
364
|
+
// so we sign manually with the agent's private key.
|
|
365
|
+
// Use the SDK's own hash computation — safeOperation.getHash() uses viem.hashTypedData
|
|
366
|
+
// This is the canonical hash that matches the on-chain verification
|
|
367
|
+
function bigintReplacer(key, val) {
|
|
368
|
+
return typeof val === 'bigint' ? val.toString() : val;
|
|
369
|
+
}
|
|
370
|
+
const safeOpHash = safeOperation.getHash();
|
|
371
|
+
const rawSafeOp = safeOperation.getSafeOperation();
|
|
372
|
+
const eip712Types = safeOperation.getEIP712Type();
|
|
373
|
+
const eip712Domain = {
|
|
374
|
+
chainId: parseInt(CHAIN_ID, 10),
|
|
375
|
+
verifyingContract: safeOperation.options.moduleAddress,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Always sign manually with the agent's private key over the canonical hash
|
|
379
|
+
// (Using SDK's signSafeOperation would apply viem hex transforms that produce
|
|
380
|
+
// a different hash than getHash(), making server-side verification impossible)
|
|
381
|
+
const sig = agentWallet.signingKey.sign(safeOpHash);
|
|
382
|
+
const agentSig = ethers.Signature.from(sig).serialized;
|
|
383
|
+
safeOperation.addSignature({
|
|
384
|
+
signer: AGENT_ADDRESS,
|
|
385
|
+
data: agentSig,
|
|
386
|
+
isContractSignature: false,
|
|
387
|
+
});
|
|
388
|
+
const signedOperation = safeOperation;
|
|
389
|
+
|
|
390
|
+
// Serialize the SafeOperation for the web app
|
|
391
|
+
const safeOperationJson = JSON.parse(JSON.stringify({
|
|
392
|
+
userOperation: signedOperation.userOperation,
|
|
393
|
+
options: signedOperation.options,
|
|
394
|
+
signatures: Object.fromEntries(signedOperation.signatures || new Map()),
|
|
395
|
+
}, bigintReplacer));
|
|
396
|
+
|
|
397
|
+
// Include EIP-712 data so web app can use eth_signTypedData_v4
|
|
398
|
+
const safeOpForSigning = JSON.parse(JSON.stringify({
|
|
399
|
+
domain: eip712Domain,
|
|
400
|
+
types: eip712Types,
|
|
401
|
+
value: rawSafeOp,
|
|
402
|
+
}, bigintReplacer));
|
|
403
|
+
|
|
404
|
+
safeOperationJson.safeOpHash = safeOpHash;
|
|
405
|
+
safeOperationJson.eip712 = safeOpForSigning;
|
|
406
|
+
|
|
407
|
+
// Compute the real EntryPoint userOpHash (for bundler receipt lookup)
|
|
408
|
+
const entryPointUserOpHash = computeUserOpHash(signedOperation.userOperation, parseInt(CHAIN_ID, 10));
|
|
409
|
+
|
|
410
|
+
const shortId = safeOpHash.slice(2, 10);
|
|
411
|
+
|
|
412
|
+
const result = {
|
|
413
|
+
userOpHash: entryPointUserOpHash,
|
|
414
|
+
safeOpHash,
|
|
415
|
+
shortId,
|
|
416
|
+
to,
|
|
417
|
+
value,
|
|
418
|
+
valueEth,
|
|
419
|
+
purpose,
|
|
420
|
+
safeAddress,
|
|
421
|
+
counterfactual: isCounterfactual,
|
|
422
|
+
status: 'pending_user_signature',
|
|
423
|
+
chainId: parseInt(CHAIN_ID, 10),
|
|
424
|
+
safeOperationJson,
|
|
425
|
+
createdAt: new Date().toISOString(),
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Save locally for tracking
|
|
429
|
+
writeFileSync(join(PENDING_DIR, `4337-${shortId}.json`), JSON.stringify(result, null, 2));
|
|
430
|
+
|
|
431
|
+
// Store to op-store API for hash-based web app lookup
|
|
432
|
+
// NOTE: signerType intentionally NOT sent — it's determined by user's browser
|
|
433
|
+
// (localStorage), not by agent. See ARCHITECTURE.md Client Verification Chain.
|
|
434
|
+
const storePayload = {
|
|
435
|
+
safeOperationJson,
|
|
436
|
+
userOpHash: entryPointUserOpHash,
|
|
437
|
+
to,
|
|
438
|
+
value,
|
|
439
|
+
valueEth,
|
|
440
|
+
// purpose intentionally NOT stored server-side — privacy by design
|
|
441
|
+
// purpose is passed via URL param in the approve link (private Telegram channel)
|
|
442
|
+
safeAddress,
|
|
443
|
+
chainId: parseInt(CHAIN_ID, 10),
|
|
444
|
+
counterfactual: isCounterfactual,
|
|
445
|
+
agent: AGENT_ADDRESS,
|
|
446
|
+
agentSignature: safeOperationJson.signatures,
|
|
447
|
+
createdAt: new Date().toISOString(),
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Extract raw agent signature for server auth
|
|
451
|
+
const agentSigEntry = Object.values(safeOperationJson.signatures || {})[0];
|
|
452
|
+
const rawAgentSignature = agentSigEntry?.data || null;
|
|
453
|
+
|
|
454
|
+
let approveUrl = null;
|
|
455
|
+
try {
|
|
456
|
+
const storeRes = await fetch(`${opStoreUrl}/tx`, {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'Content-Type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({
|
|
460
|
+
...storePayload,
|
|
461
|
+
agentSignature: rawAgentSignature,
|
|
462
|
+
agentAddress: AGENT_ADDRESS,
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
const storeData = await storeRes.json();
|
|
466
|
+
if (!storeRes.ok) {
|
|
467
|
+
result.opStoreError = storeData.error || `HTTP ${storeRes.status}`;
|
|
468
|
+
}
|
|
469
|
+
if (storeData.shortHash) {
|
|
470
|
+
const webBase = process.env.WEB_APP_URL || (isProd ? 'https://nodpay.ai' : 'https://bot.xhyumiracle.com/nodpay/index.html');
|
|
471
|
+
const purposeParam = purpose && purpose !== 'Unspecified' ? `&purpose=${encodeURIComponent(purpose)}` : '';
|
|
472
|
+
approveUrl = `${webBase}approve?safeOpHash=${storeData.safeOpHash}${purposeParam}`;
|
|
473
|
+
result.approveUrl = approveUrl;
|
|
474
|
+
result.opStoreSafeOpHash = storeData.safeOpHash;
|
|
475
|
+
result.opStoreShortHash = storeData.shortHash;
|
|
476
|
+
}
|
|
477
|
+
} catch (e) {
|
|
478
|
+
// Non-fatal: op-store might not be running
|
|
479
|
+
result.opStoreError = e.message;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
console.log(JSON.stringify(result, null, 2));
|
|
483
|
+
|
|
484
|
+
} catch (error) {
|
|
485
|
+
console.error(JSON.stringify({ error: error.message || String(error) }));
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|