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 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
+ }