@crossmint/openclaw-wallet 0.2.2 → 0.2.3
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/README.md +86 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/crossmint/SKILL.md +115 -22
- package/src/amazon-order.test.ts +528 -0
- package/src/api.ts +162 -12
- package/src/tools.ts +5 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ This plugin enables OpenClaw agents to:
|
|
|
9
9
|
- Use Crossmint smart wallets on Solana
|
|
10
10
|
- Check wallet balances (SOL, USDC, SPL tokens)
|
|
11
11
|
- Send tokens to other addresses
|
|
12
|
+
- **Buy products from Amazon** with SOL or USDC
|
|
12
13
|
|
|
13
14
|
The key innovation is **delegated signing**: the agent holds its own private key locally, and users authorize the agent to operate their Crossmint wallet through a web-based delegation flow.
|
|
14
15
|
|
|
@@ -38,7 +39,7 @@ Enable the plugin in `~/.openclaw/.openclaw.json5`:
|
|
|
38
39
|
|
|
39
40
|
## Usage
|
|
40
41
|
|
|
41
|
-
### Setting Up a Wallet (
|
|
42
|
+
### Setting Up a Wallet (3-Step Process)
|
|
42
43
|
|
|
43
44
|
**Step 1: Generate keypair and get delegation URL**
|
|
44
45
|
|
|
@@ -46,13 +47,13 @@ Ask the agent: "Set up my Crossmint wallet"
|
|
|
46
47
|
|
|
47
48
|
The agent will:
|
|
48
49
|
1. Generate a local Solana keypair (ed25519)
|
|
49
|
-
2. Provide a URL
|
|
50
|
+
2. Provide a delegation URL: `https://www.lobster.cash/configure?pubkey=<your-public-key>`
|
|
50
51
|
|
|
51
52
|
**Step 2: Complete setup on the web app**
|
|
52
53
|
|
|
53
54
|
1. Open the delegation URL in your browser
|
|
54
55
|
2. The web app will:
|
|
55
|
-
- Create a Crossmint smart wallet
|
|
56
|
+
- Create a Crossmint smart wallet on Solana devnet
|
|
56
57
|
- Add the agent's public key as a delegated signer
|
|
57
58
|
- Show you the **wallet address** and **API key**
|
|
58
59
|
|
|
@@ -75,6 +76,33 @@ The agent will:
|
|
|
75
76
|
2. Sign the transaction locally using ed25519
|
|
76
77
|
3. Submit to Crossmint for execution on Solana
|
|
77
78
|
|
|
79
|
+
### Buying from Amazon
|
|
80
|
+
|
|
81
|
+
Ask the agent: "Buy me this Amazon product: B00O79SKV6"
|
|
82
|
+
|
|
83
|
+
The agent will:
|
|
84
|
+
1. Ask for shipping address if not provided
|
|
85
|
+
2. Create an order with Crossmint
|
|
86
|
+
3. Sign the payment transaction locally
|
|
87
|
+
4. Submit the payment and confirm on-chain
|
|
88
|
+
5. Return the order ID and Solana explorer link
|
|
89
|
+
|
|
90
|
+
**Example purchase flow:**
|
|
91
|
+
```
|
|
92
|
+
User: "Buy B00O79SKV6 and ship to John Doe, 123 Main St, New York NY 10001"
|
|
93
|
+
|
|
94
|
+
Agent: ✅ Purchase complete!
|
|
95
|
+
|
|
96
|
+
Product: AmazonBasics USB Cable
|
|
97
|
+
Price: 0.05 SOL
|
|
98
|
+
Order ID: order_abc123
|
|
99
|
+
Payment: completed
|
|
100
|
+
|
|
101
|
+
Transaction: https://explorer.solana.com/tx/5x...?cluster=devnet
|
|
102
|
+
|
|
103
|
+
Use crossmint_order_status to check delivery status.
|
|
104
|
+
```
|
|
105
|
+
|
|
78
106
|
## Tools
|
|
79
107
|
|
|
80
108
|
| Tool | Description |
|
|
@@ -88,6 +116,36 @@ The agent will:
|
|
|
88
116
|
| `crossmint_buy` | Buy products from Amazon with SOL or USDC |
|
|
89
117
|
| `crossmint_order_status` | Check Amazon order/delivery status |
|
|
90
118
|
|
|
119
|
+
## Amazon Purchase Flow
|
|
120
|
+
|
|
121
|
+
When you use `crossmint_buy`, the plugin executes a complete delegated signer flow:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
125
|
+
│ 1. Create Order │
|
|
126
|
+
│ POST /orders → Returns serialized payment transaction │
|
|
127
|
+
├─────────────────────────────────────────────────────────────┤
|
|
128
|
+
│ 2. Create Transaction │
|
|
129
|
+
│ POST /wallets/{address}/transactions │
|
|
130
|
+
│ → Returns approval message to sign │
|
|
131
|
+
├─────────────────────────────────────────────────────────────┤
|
|
132
|
+
│ 3. Sign Approval (Local) │
|
|
133
|
+
│ Agent signs message with ed25519 keypair │
|
|
134
|
+
├─────────────────────────────────────────────────────────────┤
|
|
135
|
+
│ 4. Submit Approval │
|
|
136
|
+
│ POST /wallets/{address}/transactions/{id}/approvals │
|
|
137
|
+
├─────────────────────────────────────────────────────────────┤
|
|
138
|
+
│ 5. Wait for Broadcast │
|
|
139
|
+
│ Poll until on-chain txId is available │
|
|
140
|
+
├─────────────────────────────────────────────────────────────┤
|
|
141
|
+
│ 6. Confirm Payment (CRITICAL) │
|
|
142
|
+
│ POST /orders/{orderId}/payment │
|
|
143
|
+
│ → Notifies Crossmint that payment is on-chain │
|
|
144
|
+
└─────────────────────────────────────────────────────────────┘
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
All steps are handled automatically by the plugin.
|
|
148
|
+
|
|
91
149
|
## Architecture
|
|
92
150
|
|
|
93
151
|
```
|
|
@@ -102,7 +160,7 @@ The agent will:
|
|
|
102
160
|
│
|
|
103
161
|
▼
|
|
104
162
|
┌─────────────────────────────────────────────────────────────┐
|
|
105
|
-
│
|
|
163
|
+
│ Delegation Web App (lobster.cash) │
|
|
106
164
|
├─────────────────────────────────────────────────────────────┤
|
|
107
165
|
│ - Receives agent's public key via URL │
|
|
108
166
|
│ - Creates Crossmint smart wallet │
|
|
@@ -114,16 +172,17 @@ The agent will:
|
|
|
114
172
|
┌─────────────────────────────────────────────────────────────┐
|
|
115
173
|
│ Crossmint Smart Wallet │
|
|
116
174
|
├─────────────────────────────────────────────────────────────┤
|
|
117
|
-
│ - Deployed on Solana
|
|
175
|
+
│ - Deployed on Solana (devnet) │
|
|
118
176
|
│ - Agent's address registered as delegated signer │
|
|
119
177
|
│ - User retains admin control │
|
|
178
|
+
│ - Holds SOL/USDC for purchases and transfers │
|
|
120
179
|
└─────────────────────────────────────────────────────────────┘
|
|
121
180
|
```
|
|
122
181
|
|
|
123
182
|
## Security
|
|
124
183
|
|
|
125
|
-
- Private keys are stored locally on the agent's machine with secure file permissions
|
|
126
|
-
- Keys are never transmitted to Crossmint
|
|
184
|
+
- Private keys are stored locally on the agent's machine with secure file permissions (0600)
|
|
185
|
+
- Keys are never transmitted to Crossmint - only signatures
|
|
127
186
|
- Uses ed25519 cryptography (Solana native)
|
|
128
187
|
- API key is stored locally after user retrieves it from web app
|
|
129
188
|
- Users maintain admin control and can revoke delegation at any time
|
|
@@ -135,13 +194,23 @@ The agent will:
|
|
|
135
194
|
- Run `crossmint_setup` first to generate a keypair
|
|
136
195
|
|
|
137
196
|
**"Wallet not fully configured"**
|
|
138
|
-
- Complete the web setup flow
|
|
197
|
+
- Complete the web setup flow at the delegation URL
|
|
198
|
+
- Run `crossmint_configure` with wallet address and API key from the web app
|
|
139
199
|
|
|
140
200
|
**"Failed to get balance" or "Failed to send"**
|
|
141
|
-
- Verify the API key is correct
|
|
201
|
+
- Verify the API key is correct (should start with `ck_staging_`)
|
|
142
202
|
- Check that the wallet address matches the one shown in the web app
|
|
143
203
|
- Ensure the wallet has sufficient balance
|
|
144
204
|
|
|
205
|
+
**"Insufficient funds" (Amazon purchase)**
|
|
206
|
+
- Check balance with `crossmint_balance`
|
|
207
|
+
- Fund the wallet with more SOL or USDC
|
|
208
|
+
- For devnet testing, use Solana faucets for test SOL
|
|
209
|
+
|
|
210
|
+
**"Timeout waiting for transaction to be broadcast"**
|
|
211
|
+
- Check transaction status with `crossmint_tx_status`
|
|
212
|
+
- Solana network may be congested - wait and retry
|
|
213
|
+
|
|
145
214
|
## Plugin Management
|
|
146
215
|
|
|
147
216
|
```bash
|
|
@@ -155,3 +224,11 @@ openclaw plugins info openclaw-wallet
|
|
|
155
224
|
openclaw plugins enable openclaw-wallet
|
|
156
225
|
openclaw plugins disable openclaw-wallet
|
|
157
226
|
```
|
|
227
|
+
|
|
228
|
+
## Supported Currencies
|
|
229
|
+
|
|
230
|
+
| Currency | Token | Use Cases |
|
|
231
|
+
|----------|-------|-----------|
|
|
232
|
+
| SOL | Native Solana | Transfers, Amazon purchases |
|
|
233
|
+
| USDC | USD Coin | Transfers, Amazon purchases |
|
|
234
|
+
| SPL tokens | Any mint address | Transfers only |
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -43,14 +43,14 @@ User: "Set up my Crossmint wallet"
|
|
|
43
43
|
Agent: Use crossmint_setup
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
This generates a local ed25519 keypair and returns a delegation URL.
|
|
46
|
+
This generates a local ed25519 keypair and returns a delegation URL pointing to `https://www.lobster.cash/configure?pubkey=<agent-public-key>`.
|
|
47
47
|
|
|
48
48
|
### Step 2: User completes web setup
|
|
49
49
|
|
|
50
|
-
The user opens the delegation URL in their browser. The web app will:
|
|
51
|
-
1. Create a Crossmint smart wallet
|
|
50
|
+
The user opens the delegation URL in their browser. The web app (lobster.cash) will:
|
|
51
|
+
1. Create a Crossmint smart wallet on Solana devnet
|
|
52
52
|
2. Add the agent's public key as a delegated signer
|
|
53
|
-
3. Display the **wallet address** and **API key**
|
|
53
|
+
3. Display the **wallet address** and **API key** for the user to copy
|
|
54
54
|
|
|
55
55
|
### Step 3: Configure the agent
|
|
56
56
|
|
|
@@ -59,7 +59,7 @@ User: "My wallet address is X and API key is Y"
|
|
|
59
59
|
Agent: Use crossmint_configure with walletAddress and apiKey
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Now the wallet is ready to use.
|
|
62
|
+
Now the wallet is ready to use for balance checks, transfers, and Amazon purchases.
|
|
63
63
|
|
|
64
64
|
## Common Operations
|
|
65
65
|
|
|
@@ -70,7 +70,7 @@ User: "What's my wallet balance?"
|
|
|
70
70
|
Agent: Use crossmint_balance
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
Returns SOL, USDC, and other token balances.
|
|
73
|
+
Returns SOL, USDC, and other token balances on the smart wallet.
|
|
74
74
|
|
|
75
75
|
### Send tokens
|
|
76
76
|
|
|
@@ -112,6 +112,19 @@ Agent: Use crossmint_wallet_info
|
|
|
112
112
|
|
|
113
113
|
Buy products from Amazon using SOL or USDC from the agent's wallet. Crossmint acts as Merchant of Record, handling payments, shipping, and taxes.
|
|
114
114
|
|
|
115
|
+
### How It Works (Under the Hood)
|
|
116
|
+
|
|
117
|
+
When you use `crossmint_buy`, the plugin executes a 6-step delegated signer flow:
|
|
118
|
+
|
|
119
|
+
1. **Create Order** - Crossmint creates an Amazon order and returns a payment transaction
|
|
120
|
+
2. **Create Transaction** - The serialized transaction is submitted to Crossmint's wallet API
|
|
121
|
+
3. **Sign Approval** - The agent signs an approval message locally using ed25519
|
|
122
|
+
4. **Submit Approval** - The signed approval is sent to Crossmint
|
|
123
|
+
5. **Wait for Broadcast** - Poll until the transaction is confirmed on-chain
|
|
124
|
+
6. **Confirm Payment** - Submit the on-chain transaction ID to complete the order
|
|
125
|
+
|
|
126
|
+
The entire flow happens automatically - the agent handles all signing and API calls.
|
|
127
|
+
|
|
115
128
|
### Buy a product
|
|
116
129
|
|
|
117
130
|
```
|
|
@@ -121,9 +134,37 @@ Agent: Use crossmint_buy with product ASIN and shipping address
|
|
|
121
134
|
|
|
122
135
|
Required information:
|
|
123
136
|
- Amazon product ASIN or URL
|
|
124
|
-
- Recipient email
|
|
137
|
+
- Recipient email (for order confirmation)
|
|
125
138
|
- Full shipping address (name, street, city, postal code, country)
|
|
126
139
|
|
|
140
|
+
### Successful Purchase Response
|
|
141
|
+
|
|
142
|
+
When a purchase completes successfully, you'll receive:
|
|
143
|
+
- **Order ID** - Use with `crossmint_order_status` to track delivery
|
|
144
|
+
- **Transaction Explorer Link** - Solana explorer URL to verify the payment on-chain
|
|
145
|
+
- **Payment Status** - Confirms payment was processed
|
|
146
|
+
- **Product Details** - Title and price of the purchased item
|
|
147
|
+
|
|
148
|
+
Example response:
|
|
149
|
+
```
|
|
150
|
+
✅ Purchase complete!
|
|
151
|
+
|
|
152
|
+
Product: AmazonBasics USB Cable
|
|
153
|
+
Price: 0.05 SOL
|
|
154
|
+
Order ID: order_abc123
|
|
155
|
+
Payment: completed
|
|
156
|
+
|
|
157
|
+
Transaction: https://explorer.solana.com/tx/5x...?cluster=devnet
|
|
158
|
+
|
|
159
|
+
Shipping to:
|
|
160
|
+
John Doe
|
|
161
|
+
123 Main St
|
|
162
|
+
New York, NY 10001
|
|
163
|
+
US
|
|
164
|
+
|
|
165
|
+
Use crossmint_order_status to check delivery status.
|
|
166
|
+
```
|
|
167
|
+
|
|
127
168
|
### Check order status
|
|
128
169
|
|
|
129
170
|
```
|
|
@@ -131,18 +172,27 @@ User: "What's the status of my order?"
|
|
|
131
172
|
Agent: Use crossmint_order_status with the order ID
|
|
132
173
|
```
|
|
133
174
|
|
|
175
|
+
Returns:
|
|
176
|
+
- Order phase (quote, payment, delivery, completed)
|
|
177
|
+
- Payment status
|
|
178
|
+
- Delivery status
|
|
179
|
+
- Tracking information (when available)
|
|
180
|
+
|
|
134
181
|
### Amazon Product Locator Formats
|
|
135
182
|
|
|
136
|
-
|
|
183
|
+
All of these formats work:
|
|
184
|
+
- ASIN only: `B00O79SKV6`
|
|
137
185
|
- Full URL: `https://www.amazon.com/dp/B00O79SKV6`
|
|
186
|
+
- With amazon: prefix: `amazon:B00O79SKV6`
|
|
138
187
|
|
|
139
188
|
### Amazon Order Restrictions
|
|
140
189
|
|
|
141
190
|
Orders may fail if:
|
|
142
191
|
- Item not sold by Amazon or verified seller
|
|
143
|
-
- Item requires special shipping
|
|
144
|
-
- Item is digital (ebooks, software, etc.)
|
|
192
|
+
- Item requires special shipping (hazmat, oversized)
|
|
193
|
+
- Item is digital (ebooks, software, music, etc.)
|
|
145
194
|
- Item is from Amazon Fresh, Pantry, Pharmacy, or Subscribe & Save
|
|
195
|
+
- Item is out of stock or unavailable for shipping to the address
|
|
146
196
|
|
|
147
197
|
## Tool Parameters
|
|
148
198
|
|
|
@@ -214,9 +264,9 @@ Orders may fail if:
|
|
|
214
264
|
"addressLine1": "required - street address",
|
|
215
265
|
"addressLine2": "optional - apt, suite, etc.",
|
|
216
266
|
"city": "required",
|
|
217
|
-
"state": "optional - state/province code",
|
|
267
|
+
"state": "optional - state/province code (e.g., 'CA', 'NY')",
|
|
218
268
|
"postalCode": "required",
|
|
219
|
-
"country": "required - e.g., 'US'",
|
|
269
|
+
"country": "required - ISO country code (e.g., 'US')",
|
|
220
270
|
"currency": "optional - 'sol' or 'usdc' (default: 'usdc')",
|
|
221
271
|
"agentId": "optional"
|
|
222
272
|
}
|
|
@@ -248,27 +298,70 @@ Keypair exists but web setup wasn't completed.
|
|
|
248
298
|
```
|
|
249
299
|
Agent:
|
|
250
300
|
1. Use crossmint_wallet_info to get the delegation URL
|
|
251
|
-
2. Ask user to complete web setup
|
|
252
|
-
3. Use crossmint_configure with the wallet address and API key
|
|
301
|
+
2. Ask user to complete web setup at the URL
|
|
302
|
+
3. Use crossmint_configure with the wallet address and API key from the web app
|
|
253
303
|
```
|
|
254
304
|
|
|
255
305
|
### "Failed to get balance" or "Failed to send"
|
|
256
306
|
|
|
257
|
-
- Verify the API key is correct
|
|
258
|
-
- Check wallet address matches the web app
|
|
307
|
+
- Verify the API key is correct (should start with `ck_staging_` for devnet)
|
|
308
|
+
- Check wallet address matches the one shown in the web app
|
|
259
309
|
- Ensure sufficient balance for transfers
|
|
260
310
|
|
|
311
|
+
### "Insufficient funds" (Amazon purchase)
|
|
312
|
+
|
|
313
|
+
The wallet doesn't have enough SOL or USDC for the purchase.
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
Agent:
|
|
317
|
+
1. Use crossmint_balance to check current balance
|
|
318
|
+
2. Ask user to fund the wallet with more SOL/USDC
|
|
319
|
+
3. For devnet testing, use Solana faucets to get test SOL
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### "Order created but no serialized transaction returned"
|
|
323
|
+
|
|
324
|
+
The order was created but payment couldn't be prepared. This usually means:
|
|
325
|
+
- Product is unavailable or restricted
|
|
326
|
+
- Shipping address is invalid
|
|
327
|
+
- Price changed during checkout
|
|
328
|
+
|
|
329
|
+
### "Timeout waiting for transaction to be broadcast"
|
|
330
|
+
|
|
331
|
+
The transaction was signed but didn't confirm on-chain within 30 seconds.
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
Agent:
|
|
335
|
+
1. Use crossmint_tx_status to check the transaction status
|
|
336
|
+
2. If still pending, wait longer or retry
|
|
337
|
+
3. Check Solana network status for congestion
|
|
338
|
+
```
|
|
339
|
+
|
|
261
340
|
## Security Notes
|
|
262
341
|
|
|
263
342
|
- Private keys are stored locally at `~/.openclaw/crossmint-wallets/`
|
|
264
|
-
- Keys never leave the agent's machine
|
|
343
|
+
- Keys never leave the agent's machine - only signatures are sent to Crossmint
|
|
265
344
|
- Uses ed25519 cryptography (Solana native)
|
|
266
|
-
- Users retain admin control and can revoke delegation anytime
|
|
345
|
+
- Users retain admin control and can revoke delegation anytime via the Crossmint dashboard
|
|
267
346
|
- Always verify recipient addresses before sending
|
|
347
|
+
- The API key grants limited permissions - only what the user authorized during delegation
|
|
268
348
|
|
|
269
349
|
## Best Practices
|
|
270
350
|
|
|
271
|
-
1. **Always check balance before
|
|
272
|
-
2. **Confirm
|
|
273
|
-
3. **Get devnet tokens for testing** - Use Solana devnet faucets to get test SOL
|
|
274
|
-
4. **One wallet per agent** - Each agent ID gets its own keypair
|
|
351
|
+
1. **Always check balance before purchasing** - Avoid failed transactions due to insufficient funds
|
|
352
|
+
2. **Confirm shipping address with user** - Double-check addresses for Amazon purchases
|
|
353
|
+
3. **Get devnet tokens for testing** - Use Solana devnet faucets to get test SOL before trying purchases
|
|
354
|
+
4. **One wallet per agent** - Each agent ID gets its own keypair and wallet
|
|
355
|
+
5. **Save the order ID** - Users should note the order ID to track delivery status later
|
|
356
|
+
6. **Verify on-chain** - The explorer link lets users verify the payment transaction on Solana
|
|
357
|
+
|
|
358
|
+
## Supported Currencies
|
|
359
|
+
|
|
360
|
+
For Amazon purchases:
|
|
361
|
+
- **SOL** - Native Solana token
|
|
362
|
+
- **USDC** - USD Coin stablecoin on Solana
|
|
363
|
+
|
|
364
|
+
For transfers:
|
|
365
|
+
- **SOL** - Native Solana token
|
|
366
|
+
- **USDC** - USD Coin stablecoin
|
|
367
|
+
- **Any SPL token** - Specify the token mint address
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { Keypair } from "@solana/web3.js";
|
|
2
|
+
import bs58 from "bs58";
|
|
3
|
+
import nacl from "tweetnacl";
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* End-to-end test for Amazon order purchase via Crossmint.
|
|
8
|
+
*
|
|
9
|
+
* This test demonstrates the complete delegated signer flow:
|
|
10
|
+
* 1. Create an Amazon order via Crossmint API
|
|
11
|
+
* 2. Create a Crossmint transaction with the serialized transaction
|
|
12
|
+
* 3. Sign the approval message with the delegated signer
|
|
13
|
+
* 4. Submit the approval to Crossmint
|
|
14
|
+
* 5. Wait for transaction to be broadcast and get txId
|
|
15
|
+
* 6. Submit txId to /payment endpoint (CRITICAL STEP!)
|
|
16
|
+
* 7. Poll for order completion
|
|
17
|
+
*
|
|
18
|
+
* Run with:
|
|
19
|
+
* CROSSMINT_API_KEY=your-key \
|
|
20
|
+
* PAYER_ADDRESS=your-smart-wallet-address \
|
|
21
|
+
* SIGNER_PRIVATE_KEY=your-delegated-signer-base58-private-key \
|
|
22
|
+
* pnpm test src/amazon-order.test.ts
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Configuration from environment
|
|
26
|
+
const API_KEY = process.env.CROSSMINT_API_KEY || "";
|
|
27
|
+
const PAYER_ADDRESS = process.env.PAYER_ADDRESS || ""; // Smart wallet address
|
|
28
|
+
const SIGNER_PRIVATE_KEY = process.env.PAYER_PRIVATE_KEY || ""; // Delegated signer private key
|
|
29
|
+
|
|
30
|
+
// Crossmint API base URLs (staging = devnet)
|
|
31
|
+
const CROSSMINT_ORDERS_API = "https://staging.crossmint.com/api/2022-06-09";
|
|
32
|
+
const CROSSMINT_WALLETS_API = "https://staging.crossmint.com/api/2025-06-09";
|
|
33
|
+
|
|
34
|
+
// Test product - can be overridden via environment variable
|
|
35
|
+
const TEST_AMAZON_ASIN = process.env.TEST_AMAZON_ASIN || "B00AATAHY0";
|
|
36
|
+
|
|
37
|
+
// Skip tests if credentials not provided
|
|
38
|
+
const LIVE = API_KEY && PAYER_ADDRESS && SIGNER_PRIVATE_KEY;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Step 1: Create the Amazon Order
|
|
42
|
+
*/
|
|
43
|
+
async function createAmazonOrder(amazonASIN: string, payerAddress: string, currency: string = "sol") {
|
|
44
|
+
const response = await fetch(`${CROSSMINT_ORDERS_API}/orders`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
"X-API-KEY": API_KEY,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
recipient: {
|
|
52
|
+
email: "buyer@example.com",
|
|
53
|
+
physicalAddress: {
|
|
54
|
+
name: "John Doe",
|
|
55
|
+
line1: "350 5th Ave",
|
|
56
|
+
line2: "Suite 400",
|
|
57
|
+
city: "New York",
|
|
58
|
+
state: "NY",
|
|
59
|
+
postalCode: "10118",
|
|
60
|
+
country: "US",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
payment: {
|
|
64
|
+
method: "solana",
|
|
65
|
+
currency: currency,
|
|
66
|
+
payerAddress: payerAddress,
|
|
67
|
+
receiptEmail: "buyer@example.com",
|
|
68
|
+
},
|
|
69
|
+
lineItems: [
|
|
70
|
+
{
|
|
71
|
+
productLocator: `amazon:${amazonASIN}`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
const error = await response.text();
|
|
79
|
+
throw new Error(`Failed to create order: ${error}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return await response.json();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Step 2: Create Crossmint transaction from serialized transaction
|
|
87
|
+
* Returns transactionId and message to sign
|
|
88
|
+
*/
|
|
89
|
+
async function createCrossmintTransaction(
|
|
90
|
+
payerAddress: string,
|
|
91
|
+
serializedTransaction: string,
|
|
92
|
+
signerAddress: string
|
|
93
|
+
) {
|
|
94
|
+
const response = await fetch(
|
|
95
|
+
`${CROSSMINT_WALLETS_API}/wallets/${encodeURIComponent(payerAddress)}/transactions`,
|
|
96
|
+
{
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
"X-API-KEY": API_KEY,
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
params: {
|
|
104
|
+
transaction: serializedTransaction,
|
|
105
|
+
signer: `external-wallet:${signerAddress}`,
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const error = await response.text();
|
|
113
|
+
throw new Error(`Failed to create transaction: ${error}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return await response.json();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Step 3: Sign the approval message with delegated signer
|
|
121
|
+
*/
|
|
122
|
+
function signApprovalMessage(message: string, privateKeyBase58: string): string {
|
|
123
|
+
const secretKey = bs58.decode(privateKeyBase58);
|
|
124
|
+
const messageBytes = bs58.decode(message);
|
|
125
|
+
const signature = nacl.sign.detached(messageBytes, secretKey);
|
|
126
|
+
return bs58.encode(signature);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Step 4: Submit approval to Crossmint
|
|
131
|
+
*/
|
|
132
|
+
async function submitApproval(
|
|
133
|
+
payerAddress: string,
|
|
134
|
+
transactionId: string,
|
|
135
|
+
signerAddress: string,
|
|
136
|
+
signature: string
|
|
137
|
+
) {
|
|
138
|
+
const response = await fetch(
|
|
139
|
+
`${CROSSMINT_WALLETS_API}/wallets/${encodeURIComponent(payerAddress)}/transactions/${encodeURIComponent(transactionId)}/approvals`,
|
|
140
|
+
{
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
"X-API-KEY": API_KEY,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
approvals: [
|
|
148
|
+
{
|
|
149
|
+
signer: `external-wallet:${signerAddress}`,
|
|
150
|
+
signature: signature,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
const error = await response.text();
|
|
159
|
+
throw new Error(`Failed to submit approval: ${error}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return await response.json();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Step 5: Get transaction status from Crossmint Wallets API
|
|
167
|
+
* Poll until we get the on-chain txId
|
|
168
|
+
*/
|
|
169
|
+
async function getTransactionStatus(payerAddress: string, transactionId: string) {
|
|
170
|
+
const response = await fetch(
|
|
171
|
+
`${CROSSMINT_WALLETS_API}/wallets/${encodeURIComponent(payerAddress)}/transactions/${encodeURIComponent(transactionId)}`,
|
|
172
|
+
{
|
|
173
|
+
method: "GET",
|
|
174
|
+
headers: {
|
|
175
|
+
"Content-Type": "application/json",
|
|
176
|
+
"X-API-KEY": API_KEY,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const error = await response.text();
|
|
183
|
+
throw new Error(`Failed to get transaction status: ${error}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return await response.json();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Step 5b: Poll for transaction to be broadcast and get on-chain txId
|
|
191
|
+
*/
|
|
192
|
+
async function waitForTransactionBroadcast(
|
|
193
|
+
payerAddress: string,
|
|
194
|
+
transactionId: string,
|
|
195
|
+
timeoutMs: number = 30000
|
|
196
|
+
): Promise<string | null> {
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
|
|
199
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
200
|
+
const txStatus = await getTransactionStatus(payerAddress, transactionId);
|
|
201
|
+
console.log("Transaction status:", txStatus.status);
|
|
202
|
+
|
|
203
|
+
// Check if transaction has been broadcast and we have the on-chain txId
|
|
204
|
+
if (txStatus.onChain?.txId) {
|
|
205
|
+
console.log("On-chain txId:", txStatus.onChain.txId);
|
|
206
|
+
return txStatus.onChain.txId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Also check for txId directly on the response
|
|
210
|
+
if (txStatus.txId) {
|
|
211
|
+
console.log("txId from response:", txStatus.txId);
|
|
212
|
+
return txStatus.txId;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if status indicates completion
|
|
216
|
+
if (txStatus.status === "success" || txStatus.status === "completed") {
|
|
217
|
+
// Try to find txId in various places
|
|
218
|
+
const txId = txStatus.onChain?.txId || txStatus.txId || txStatus.hash;
|
|
219
|
+
if (txId) {
|
|
220
|
+
return txId;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check for failure
|
|
225
|
+
if (txStatus.status === "failed") {
|
|
226
|
+
throw new Error(`Transaction failed: ${JSON.stringify(txStatus)}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Wait before polling again
|
|
230
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log("Timeout waiting for transaction broadcast");
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Step 6: Submit payment to Crossmint Orders API (CRITICAL!)
|
|
239
|
+
* This notifies Crossmint that the payment transaction has been submitted
|
|
240
|
+
*/
|
|
241
|
+
async function processPayment(orderId: string, txId: string, clientSecret?: string) {
|
|
242
|
+
const headers: Record<string, string> = {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
"X-API-KEY": API_KEY,
|
|
245
|
+
};
|
|
246
|
+
if (clientSecret) {
|
|
247
|
+
headers["Authorization"] = clientSecret;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const response = await fetch(`${CROSSMINT_ORDERS_API}/orders/${orderId}/payment`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers,
|
|
253
|
+
body: JSON.stringify({
|
|
254
|
+
type: "crypto-tx-id",
|
|
255
|
+
txId: txId,
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
const error = await response.text();
|
|
261
|
+
throw new Error(`Failed to process payment: ${error}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return await response.json();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Step 7: Poll for Order Completion
|
|
269
|
+
*/
|
|
270
|
+
async function pollOrderStatus(
|
|
271
|
+
orderId: string,
|
|
272
|
+
clientSecret: string,
|
|
273
|
+
timeoutMs: number = 60000
|
|
274
|
+
): Promise<{ paymentStatus: string; deliveryStatus: string }> {
|
|
275
|
+
return new Promise((resolve) => {
|
|
276
|
+
const intervalId = setInterval(async () => {
|
|
277
|
+
try {
|
|
278
|
+
const response = await fetch(`${CROSSMINT_ORDERS_API}/orders/${orderId}`, {
|
|
279
|
+
method: "GET",
|
|
280
|
+
headers: {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"X-API-KEY": API_KEY,
|
|
283
|
+
Authorization: clientSecret,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (!response.ok) {
|
|
288
|
+
console.log("Failed to get order status:", await response.text());
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const orderStatus = await response.json();
|
|
293
|
+
const paymentStatus = orderStatus.payment?.status || "unknown";
|
|
294
|
+
const deliveryStatus = orderStatus.lineItems?.[0]?.delivery?.status || "pending";
|
|
295
|
+
|
|
296
|
+
console.log("Payment:", paymentStatus);
|
|
297
|
+
console.log("Delivery:", deliveryStatus);
|
|
298
|
+
|
|
299
|
+
if (paymentStatus === "completed") {
|
|
300
|
+
clearInterval(intervalId);
|
|
301
|
+
console.log("Payment completed! Amazon order is being fulfilled.");
|
|
302
|
+
resolve({ paymentStatus, deliveryStatus });
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error("Error polling order status:", error);
|
|
306
|
+
}
|
|
307
|
+
}, 2500);
|
|
308
|
+
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
clearInterval(intervalId);
|
|
311
|
+
console.log("Timeout - check order manually");
|
|
312
|
+
resolve({ paymentStatus: "timeout", deliveryStatus: "unknown" });
|
|
313
|
+
}, timeoutMs);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get order status (single call)
|
|
319
|
+
*/
|
|
320
|
+
async function getOrderStatus(orderId: string, clientSecret?: string) {
|
|
321
|
+
const headers: Record<string, string> = {
|
|
322
|
+
"Content-Type": "application/json",
|
|
323
|
+
"X-API-KEY": API_KEY,
|
|
324
|
+
};
|
|
325
|
+
if (clientSecret) {
|
|
326
|
+
headers["Authorization"] = clientSecret;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const response = await fetch(`${CROSSMINT_ORDERS_API}/orders/${orderId}`, {
|
|
330
|
+
method: "GET",
|
|
331
|
+
headers,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!response.ok) {
|
|
335
|
+
const error = await response.text();
|
|
336
|
+
throw new Error(`Failed to get order: ${error}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return await response.json();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get signer public key from private key
|
|
344
|
+
*/
|
|
345
|
+
function getSignerAddress(privateKeyBase58: string): string {
|
|
346
|
+
const secretKey = bs58.decode(privateKeyBase58);
|
|
347
|
+
const keypair = Keypair.fromSecretKey(secretKey);
|
|
348
|
+
return keypair.publicKey.toBase58();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
describe("Amazon Order E2E Test", () => {
|
|
352
|
+
describe.skipIf(!LIVE)("live: delegated signer purchase flow", () => {
|
|
353
|
+
it("creates order and pays with delegated signer approval", async () => {
|
|
354
|
+
const signerAddress = getSignerAddress(SIGNER_PRIVATE_KEY);
|
|
355
|
+
|
|
356
|
+
console.log("\n=== Starting Amazon Order E2E Test (Delegated Signer Flow) ===\n");
|
|
357
|
+
console.log("Smart Wallet (Payer):", PAYER_ADDRESS);
|
|
358
|
+
console.log("Delegated Signer:", signerAddress);
|
|
359
|
+
console.log("Amazon ASIN:", TEST_AMAZON_ASIN);
|
|
360
|
+
|
|
361
|
+
// Step 1: Create the order
|
|
362
|
+
console.log("\n--- Step 1: Creating Amazon order ---");
|
|
363
|
+
const { order, clientSecret } = await createAmazonOrder(
|
|
364
|
+
TEST_AMAZON_ASIN,
|
|
365
|
+
PAYER_ADDRESS,
|
|
366
|
+
"sol"
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
console.log("Order ID:", order.orderId);
|
|
370
|
+
console.log("Order Phase:", order.phase);
|
|
371
|
+
console.log("Client Secret:", clientSecret ? "✓ received" : "✗ missing");
|
|
372
|
+
|
|
373
|
+
expect(order.orderId).toBeDefined();
|
|
374
|
+
expect(order.phase).toBeDefined();
|
|
375
|
+
|
|
376
|
+
// Check if we have a serialized transaction
|
|
377
|
+
const serializedTransaction = order.payment?.preparation?.serializedTransaction;
|
|
378
|
+
console.log(
|
|
379
|
+
"Serialized Transaction:",
|
|
380
|
+
serializedTransaction ? `✓ received (${serializedTransaction.length} chars)` : "✗ missing"
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (!serializedTransaction) {
|
|
384
|
+
console.log("\nOrder created but no serialized transaction returned.");
|
|
385
|
+
console.log("Full order response:", JSON.stringify(order, null, 2));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Check for insufficient funds
|
|
390
|
+
if (order.payment?.failureReason?.code === "insufficient-funds") {
|
|
391
|
+
console.log("\n⚠️ Insufficient funds:", order.payment.failureReason.message);
|
|
392
|
+
console.log("Please fund the wallet and try again.");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Step 2: Create Crossmint transaction
|
|
397
|
+
console.log("\n--- Step 2: Creating Crossmint transaction ---");
|
|
398
|
+
const txResponse = await createCrossmintTransaction(
|
|
399
|
+
PAYER_ADDRESS,
|
|
400
|
+
serializedTransaction,
|
|
401
|
+
signerAddress
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
console.log("Transaction ID:", txResponse.id);
|
|
405
|
+
console.log("Transaction Status:", txResponse.status);
|
|
406
|
+
|
|
407
|
+
const messageToSign = txResponse.approvals?.pending?.[0]?.message;
|
|
408
|
+
console.log("Message to sign:", messageToSign ? `✓ received (${messageToSign.length} chars)` : "✗ missing");
|
|
409
|
+
|
|
410
|
+
if (!messageToSign) {
|
|
411
|
+
console.log("\nNo message to sign. Transaction response:", JSON.stringify(txResponse, null, 2));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Step 3: Sign the approval message
|
|
416
|
+
console.log("\n--- Step 3: Signing approval message ---");
|
|
417
|
+
const signature = signApprovalMessage(messageToSign, SIGNER_PRIVATE_KEY);
|
|
418
|
+
console.log("Signature:", `✓ generated (${signature.length} chars)`);
|
|
419
|
+
|
|
420
|
+
// Step 4: Submit approval
|
|
421
|
+
console.log("\n--- Step 4: Submitting approval to Crossmint ---");
|
|
422
|
+
const approvalResponse = await submitApproval(
|
|
423
|
+
PAYER_ADDRESS,
|
|
424
|
+
txResponse.id,
|
|
425
|
+
signerAddress,
|
|
426
|
+
signature
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
console.log("Approval Response:", JSON.stringify(approvalResponse, null, 2));
|
|
430
|
+
|
|
431
|
+
// Step 5: Wait for transaction to be broadcast and get on-chain txId
|
|
432
|
+
console.log("\n--- Step 5: Waiting for transaction broadcast ---");
|
|
433
|
+
const onChainTxId = await waitForTransactionBroadcast(PAYER_ADDRESS, txResponse.id, 30000);
|
|
434
|
+
|
|
435
|
+
if (!onChainTxId) {
|
|
436
|
+
console.log("Could not get on-chain txId. Checking approval response for txId...");
|
|
437
|
+
// Try to get txId from approval response
|
|
438
|
+
const fallbackTxId = approvalResponse.onChain?.txId || approvalResponse.txId || approvalResponse.hash;
|
|
439
|
+
if (!fallbackTxId) {
|
|
440
|
+
console.log("No txId available. Cannot call /payment endpoint.");
|
|
441
|
+
console.log("Full approval response:", JSON.stringify(approvalResponse, null, 2));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const txIdToSubmit = onChainTxId || approvalResponse.onChain?.txId || approvalResponse.txId;
|
|
447
|
+
|
|
448
|
+
// Step 6: Submit payment to Crossmint (CRITICAL!)
|
|
449
|
+
console.log("\n--- Step 6: Submitting payment to Crossmint /payment endpoint ---");
|
|
450
|
+
console.log("Submitting txId:", txIdToSubmit);
|
|
451
|
+
|
|
452
|
+
const paymentResponse = await processPayment(order.orderId, txIdToSubmit!, clientSecret);
|
|
453
|
+
console.log("Payment Response:", JSON.stringify(paymentResponse, null, 2));
|
|
454
|
+
|
|
455
|
+
// Step 7: Poll for order completion
|
|
456
|
+
console.log("\n--- Step 7: Polling for order completion ---");
|
|
457
|
+
const { paymentStatus, deliveryStatus } = await pollOrderStatus(
|
|
458
|
+
order.orderId,
|
|
459
|
+
clientSecret,
|
|
460
|
+
60000
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
console.log("\n=== Final Status ===");
|
|
464
|
+
console.log("Payment Status:", paymentStatus);
|
|
465
|
+
console.log("Delivery Status:", deliveryStatus);
|
|
466
|
+
|
|
467
|
+
// Payment should eventually complete
|
|
468
|
+
expect(["completed", "timeout", "crypto-payer-insufficient-funds"]).toContain(paymentStatus);
|
|
469
|
+
}, 180000); // 3 minute timeout
|
|
470
|
+
|
|
471
|
+
it("creates order only (inspect response)", async () => {
|
|
472
|
+
console.log("\n=== Create Order Only Test ===\n");
|
|
473
|
+
|
|
474
|
+
const { order, clientSecret } = await createAmazonOrder(
|
|
475
|
+
TEST_AMAZON_ASIN,
|
|
476
|
+
PAYER_ADDRESS,
|
|
477
|
+
"sol"
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
console.log("Order ID:", order.orderId);
|
|
481
|
+
console.log("Order Phase:", order.phase);
|
|
482
|
+
console.log("Quote:", JSON.stringify(order.quote, null, 2));
|
|
483
|
+
console.log("Payment:", JSON.stringify(order.payment, null, 2));
|
|
484
|
+
|
|
485
|
+
expect(order.orderId).toBeDefined();
|
|
486
|
+
|
|
487
|
+
console.log("\n--- Full Response ---");
|
|
488
|
+
console.log(JSON.stringify({ order, clientSecret }, null, 2));
|
|
489
|
+
}, 30000);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("unit tests", () => {
|
|
493
|
+
it("validates keypair creation from base58", () => {
|
|
494
|
+
const testKeypair = Keypair.generate();
|
|
495
|
+
const secretKeyBase58 = bs58.encode(testKeypair.secretKey);
|
|
496
|
+
const recreatedKeypair = Keypair.fromSecretKey(bs58.decode(secretKeyBase58));
|
|
497
|
+
expect(recreatedKeypair.publicKey.toBase58()).toBe(testKeypair.publicKey.toBase58());
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("validates signature generation", () => {
|
|
501
|
+
const testKeypair = Keypair.generate();
|
|
502
|
+
const secretKeyBase58 = bs58.encode(testKeypair.secretKey);
|
|
503
|
+
const testMessage = bs58.encode(Buffer.from("test message"));
|
|
504
|
+
|
|
505
|
+
const signature = signApprovalMessage(testMessage, secretKeyBase58);
|
|
506
|
+
expect(signature).toBeDefined();
|
|
507
|
+
expect(signature.length).toBeGreaterThan(0);
|
|
508
|
+
|
|
509
|
+
// Verify signature
|
|
510
|
+
const sigBytes = bs58.decode(signature);
|
|
511
|
+
const msgBytes = bs58.decode(testMessage);
|
|
512
|
+
const isValid = nacl.sign.detached.verify(msgBytes, sigBytes, testKeypair.publicKey.toBytes());
|
|
513
|
+
expect(isValid).toBe(true);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
export {
|
|
519
|
+
createAmazonOrder,
|
|
520
|
+
createCrossmintTransaction,
|
|
521
|
+
signApprovalMessage,
|
|
522
|
+
submitApproval,
|
|
523
|
+
waitForTransactionBroadcast,
|
|
524
|
+
processPayment,
|
|
525
|
+
pollOrderStatus,
|
|
526
|
+
getOrderStatus,
|
|
527
|
+
getTransactionStatus,
|
|
528
|
+
};
|
package/src/api.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type CrossmintTransaction = {
|
|
|
17
17
|
id: string;
|
|
18
18
|
status: string;
|
|
19
19
|
hash?: string;
|
|
20
|
+
txId?: string; // Top-level txId from API response
|
|
20
21
|
explorerLink?: string;
|
|
21
22
|
onChain?: {
|
|
22
23
|
status?: string;
|
|
@@ -109,6 +110,7 @@ export async function getTransactionStatus(
|
|
|
109
110
|
id: data.id,
|
|
110
111
|
status: data.status,
|
|
111
112
|
hash: data.onChain?.txId || data.hash,
|
|
113
|
+
txId: data.txId, // Top-level txId from API response
|
|
112
114
|
explorerLink: data.onChain?.txId
|
|
113
115
|
? `https://explorer.solana.com/tx/${data.onChain.txId}?cluster=devnet`
|
|
114
116
|
: undefined,
|
|
@@ -319,14 +321,19 @@ export type TransactionResponse = {
|
|
|
319
321
|
// Headless Checkout API Functions (Delegated Signer Flow)
|
|
320
322
|
// ============================================================================
|
|
321
323
|
|
|
324
|
+
export type CreateOrderResponse = {
|
|
325
|
+
order: CrossmintOrder;
|
|
326
|
+
clientSecret: string;
|
|
327
|
+
};
|
|
328
|
+
|
|
322
329
|
/**
|
|
323
330
|
* Step 1: Create an order for purchasing products (e.g., from Amazon)
|
|
324
|
-
* Returns order with serializedTransaction
|
|
331
|
+
* Returns order with serializedTransaction and clientSecret for subsequent API calls
|
|
325
332
|
*/
|
|
326
333
|
export async function createOrder(
|
|
327
334
|
config: CrossmintApiConfig,
|
|
328
335
|
request: CreateOrderRequest,
|
|
329
|
-
): Promise<
|
|
336
|
+
): Promise<CreateOrderResponse> {
|
|
330
337
|
const response = await fetchCrossmint(config, "/2022-06-09/orders", {
|
|
331
338
|
method: "POST",
|
|
332
339
|
body: JSON.stringify(request),
|
|
@@ -337,9 +344,12 @@ export async function createOrder(
|
|
|
337
344
|
throw new Error(`Failed to create order: ${error}`);
|
|
338
345
|
}
|
|
339
346
|
|
|
340
|
-
// API returns { clientSecret, order }
|
|
347
|
+
// API returns { clientSecret, order }
|
|
341
348
|
const data = await response.json();
|
|
342
|
-
return
|
|
349
|
+
return {
|
|
350
|
+
order: data.order,
|
|
351
|
+
clientSecret: data.clientSecret,
|
|
352
|
+
};
|
|
343
353
|
}
|
|
344
354
|
|
|
345
355
|
/**
|
|
@@ -414,11 +424,17 @@ export async function submitApproval(
|
|
|
414
424
|
export async function getOrder(
|
|
415
425
|
config: CrossmintApiConfig,
|
|
416
426
|
orderId: string,
|
|
427
|
+
clientSecret?: string,
|
|
417
428
|
): Promise<CrossmintOrder> {
|
|
429
|
+
const headers: Record<string, string> = {};
|
|
430
|
+
if (clientSecret) {
|
|
431
|
+
headers["Authorization"] = clientSecret;
|
|
432
|
+
}
|
|
433
|
+
|
|
418
434
|
const response = await fetchCrossmint(
|
|
419
435
|
config,
|
|
420
436
|
`/2022-06-09/orders/${encodeURIComponent(orderId)}`,
|
|
421
|
-
{ method: "GET" },
|
|
437
|
+
{ method: "GET", headers },
|
|
422
438
|
);
|
|
423
439
|
|
|
424
440
|
if (!response.ok) {
|
|
@@ -429,20 +445,124 @@ export async function getOrder(
|
|
|
429
445
|
return response.json();
|
|
430
446
|
}
|
|
431
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Step 5: Wait for transaction to be broadcast and get on-chain txId
|
|
450
|
+
* Polls the transaction status until we get the on-chain transaction ID
|
|
451
|
+
*/
|
|
452
|
+
export async function waitForTransactionBroadcast(
|
|
453
|
+
config: CrossmintApiConfig,
|
|
454
|
+
walletAddress: string,
|
|
455
|
+
transactionId: string,
|
|
456
|
+
timeoutMs: number = 30000,
|
|
457
|
+
pollIntervalMs: number = 2000,
|
|
458
|
+
): Promise<string | null> {
|
|
459
|
+
const startTime = Date.now();
|
|
460
|
+
|
|
461
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
462
|
+
const txStatus = await getTransactionStatus(config, walletAddress, transactionId);
|
|
463
|
+
|
|
464
|
+
// Check if transaction has been broadcast and we have the on-chain txId
|
|
465
|
+
if (txStatus.onChain?.txId) {
|
|
466
|
+
return txStatus.onChain.txId;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Also check for txId directly on the response
|
|
470
|
+
if (txStatus.txId) {
|
|
471
|
+
return txStatus.txId;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Check if status indicates completion
|
|
475
|
+
if (txStatus.status === "success" || txStatus.status === "completed") {
|
|
476
|
+
// Try to find txId in various places
|
|
477
|
+
const txId = txStatus.onChain?.txId || txStatus.txId || txStatus.hash;
|
|
478
|
+
if (txId) {
|
|
479
|
+
return txId;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Check for failure
|
|
484
|
+
if (txStatus.status === "failed") {
|
|
485
|
+
throw new Error(`Transaction failed: ${JSON.stringify(txStatus)}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Wait before polling again
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Step 6: Submit payment confirmation to Crossmint Orders API (CRITICAL!)
|
|
497
|
+
* This notifies Crossmint that the payment transaction has been submitted on-chain
|
|
498
|
+
*/
|
|
499
|
+
export async function submitPaymentConfirmation(
|
|
500
|
+
config: CrossmintApiConfig,
|
|
501
|
+
orderId: string,
|
|
502
|
+
onChainTxId: string,
|
|
503
|
+
clientSecret?: string,
|
|
504
|
+
): Promise<CrossmintOrder> {
|
|
505
|
+
const headers: Record<string, string> = {};
|
|
506
|
+
if (clientSecret) {
|
|
507
|
+
headers["Authorization"] = clientSecret;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const response = await fetchCrossmint(
|
|
511
|
+
config,
|
|
512
|
+
`/2022-06-09/orders/${encodeURIComponent(orderId)}/payment`,
|
|
513
|
+
{
|
|
514
|
+
method: "POST",
|
|
515
|
+
headers,
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
type: "crypto-tx-id",
|
|
518
|
+
txId: onChainTxId,
|
|
519
|
+
}),
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
if (!response.ok) {
|
|
524
|
+
const error = await response.text();
|
|
525
|
+
throw new Error(`Failed to submit payment confirmation: ${error}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return response.json();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export type PurchaseResult = {
|
|
532
|
+
order: CrossmintOrder;
|
|
533
|
+
transactionId: string;
|
|
534
|
+
onChainTxId: string;
|
|
535
|
+
explorerLink: string;
|
|
536
|
+
};
|
|
537
|
+
|
|
432
538
|
/**
|
|
433
539
|
* Complete Amazon purchase flow with delegated signer
|
|
434
|
-
*
|
|
540
|
+
*
|
|
541
|
+
* Steps:
|
|
542
|
+
* 1. Create order → returns serializedTransaction and clientSecret
|
|
543
|
+
* 2a. Create Crossmint transaction with serialized tx
|
|
544
|
+
* 2b. Sign the approval message locally with ed25519
|
|
545
|
+
* 2c. Submit approval to Crossmint
|
|
546
|
+
* 5. Wait for transaction to be broadcast → get on-chain txId
|
|
547
|
+
* 6. Submit txId to /payment endpoint (CRITICAL!)
|
|
548
|
+
*
|
|
549
|
+
* Returns the final order state after payment confirmation.
|
|
435
550
|
*/
|
|
436
551
|
export async function purchaseProduct(
|
|
437
552
|
config: CrossmintApiConfig,
|
|
438
553
|
request: CreateOrderRequest,
|
|
439
554
|
keypair: Keypair,
|
|
440
|
-
): Promise<
|
|
555
|
+
): Promise<PurchaseResult> {
|
|
441
556
|
// Step 1: Create order
|
|
442
|
-
const order = await createOrder(config, request);
|
|
557
|
+
const { order, clientSecret } = await createOrder(config, request);
|
|
443
558
|
|
|
444
559
|
const serializedTransaction = order.payment?.preparation?.serializedTransaction;
|
|
445
560
|
if (!serializedTransaction) {
|
|
561
|
+
// Check for insufficient funds
|
|
562
|
+
const failureReason = (order.payment as { failureReason?: { code: string; message: string } })?.failureReason;
|
|
563
|
+
if (failureReason?.code === "insufficient-funds") {
|
|
564
|
+
throw new Error(`Insufficient funds: ${failureReason.message}`);
|
|
565
|
+
}
|
|
446
566
|
throw new Error(
|
|
447
567
|
`Order created but no serialized transaction returned. Payment status: ${order.payment?.status || "unknown"}`,
|
|
448
568
|
);
|
|
@@ -461,14 +581,14 @@ export async function purchaseProduct(
|
|
|
461
581
|
throw new Error("Transaction created but no message to sign");
|
|
462
582
|
}
|
|
463
583
|
|
|
464
|
-
// Step
|
|
465
|
-
// Message is base58 encoded (Solana standard)
|
|
584
|
+
// Step 2b (local): Sign the message with ed25519
|
|
585
|
+
// Message is base58 encoded (Solana standard)
|
|
466
586
|
const messageBytes = bs58.decode(messageToSign);
|
|
467
587
|
const nacl = (await import("tweetnacl")).default;
|
|
468
588
|
const signature = nacl.sign.detached(messageBytes, keypair.secretKey);
|
|
469
589
|
const signatureBase58 = bs58.encode(signature);
|
|
470
590
|
|
|
471
|
-
// Step
|
|
591
|
+
// Step 2c: Submit approval
|
|
472
592
|
await submitApproval(
|
|
473
593
|
config,
|
|
474
594
|
request.payment.payerAddress,
|
|
@@ -477,7 +597,37 @@ export async function purchaseProduct(
|
|
|
477
597
|
signatureBase58,
|
|
478
598
|
);
|
|
479
599
|
|
|
480
|
-
|
|
600
|
+
// Step 5: Wait for transaction to be broadcast and get on-chain txId
|
|
601
|
+
const onChainTxId = await waitForTransactionBroadcast(
|
|
602
|
+
config,
|
|
603
|
+
request.payment.payerAddress,
|
|
604
|
+
txResponse.id,
|
|
605
|
+
30000, // 30 second timeout
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
if (!onChainTxId) {
|
|
609
|
+
throw new Error("Timeout waiting for transaction to be broadcast on-chain");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Step 6: Submit payment confirmation (CRITICAL!)
|
|
613
|
+
const updatedOrder = await submitPaymentConfirmation(
|
|
614
|
+
config,
|
|
615
|
+
order.orderId,
|
|
616
|
+
onChainTxId,
|
|
617
|
+
clientSecret,
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const explorerLink =
|
|
621
|
+
config.environment === "staging"
|
|
622
|
+
? `https://explorer.solana.com/tx/${onChainTxId}?cluster=devnet`
|
|
623
|
+
: `https://explorer.solana.com/tx/${onChainTxId}`;
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
order: updatedOrder,
|
|
627
|
+
transactionId: txResponse.id,
|
|
628
|
+
onChainTxId,
|
|
629
|
+
explorerLink,
|
|
630
|
+
};
|
|
481
631
|
}
|
|
482
632
|
|
|
483
633
|
/**
|
package/src/tools.ts
CHANGED
|
@@ -675,18 +675,22 @@ export function createCrossmintBuyTool(_api: OpenClawPluginApi, _config: Crossmi
|
|
|
675
675
|
const productTitle = result.order.lineItems?.[0]?.metadata?.title || "Product";
|
|
676
676
|
const totalPrice = result.order.quote?.totalPrice;
|
|
677
677
|
const priceText = totalPrice ? `${totalPrice.amount} ${totalPrice.currency}` : "See order details";
|
|
678
|
+
const paymentStatus = result.order.payment?.status || result.order.phase;
|
|
678
679
|
|
|
679
680
|
return {
|
|
680
681
|
content: [
|
|
681
682
|
{
|
|
682
683
|
type: "text",
|
|
683
|
-
text:
|
|
684
|
+
text: `✅ Purchase complete!\n\nProduct: ${productTitle}\nPrice: ${priceText}\nOrder ID: ${result.order.orderId}\nPayment: ${paymentStatus}\n\nTransaction: ${result.explorerLink}\n\nShipping to:\n${recipientName}\n${addressLine1}${addressLine2 ? `\n${addressLine2}` : ""}\n${city}${state ? `, ${state}` : ""} ${postalCode}\n${country}\n\nUse crossmint_order_status to check delivery status.`,
|
|
684
685
|
},
|
|
685
686
|
],
|
|
686
687
|
details: {
|
|
687
688
|
orderId: result.order.orderId,
|
|
688
689
|
transactionId: result.transactionId,
|
|
690
|
+
onChainTxId: result.onChainTxId,
|
|
691
|
+
explorerLink: result.explorerLink,
|
|
689
692
|
phase: result.order.phase,
|
|
693
|
+
paymentStatus,
|
|
690
694
|
productLocator,
|
|
691
695
|
totalPrice,
|
|
692
696
|
recipient: orderRequest.recipient,
|