From 5a1766ffd0bb2ddf6fce7d67f51657044a50860f Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 23 Mar 2026 21:18:16 -0700 Subject: [PATCH 1/7] Add a skill for testing the sandbox usdc integrations e2e. --- .claude/skills/grid-usdc-sandbox/SKILL.md | 539 ++++++++++++++++++ .../skills/grid-usdc-sandbox/solana_helper.py | 254 +++++++++ 2 files changed, 793 insertions(+) create mode 100644 .claude/skills/grid-usdc-sandbox/SKILL.md create mode 100644 .claude/skills/grid-usdc-sandbox/solana_helper.py diff --git a/.claude/skills/grid-usdc-sandbox/SKILL.md b/.claude/skills/grid-usdc-sandbox/SKILL.md new file mode 100644 index 00000000..d9258340 --- /dev/null +++ b/.claude/skills/grid-usdc-sandbox/SKILL.md @@ -0,0 +1,539 @@ +--- +name: grid-usdc-sandbox +description: > + End-to-end USDC sandbox flow tests using real Solana devnet funds. Use when the user asks to + "test USDC flows", "run sandbox tests", "test deposits and withdrawals", "test USDC sandbox", + "run e2e USDC test", "test realtime funding", "test USDC to USD", "test USDC to MXN", + or wants to verify Grid's USDC deposit/withdrawal/quote pipeline on devnet. +allowed-tools: + - Bash + - Read + - Grep + - Glob + - WebFetch +--- + +# Grid USDC Sandbox Flow Test + +End-to-end test of USDC sandbox flows: deposits, withdrawals, and cross-currency quotes using real Solana devnet funds. + +## Prerequisites + +Run these steps before any tests. Stop and report if any step fails. + +### 1. Load Grid API credentials + +```bash +export GRID_API_TOKEN_ID=$(jq -r .apiTokenId ~/.grid-credentials) +export GRID_API_CLIENT_SECRET=$(jq -r .apiClientSecret ~/.grid-credentials) +export GRID_BASE_URL=$(jq -r '.baseUrl // "https://api.lightspark.com/grid/2025-10-13"' ~/.grid-credentials) +``` + +### 2. Verify Solana devnet key exists + +```bash +jq -r '.solanaDevnetPrivateKey // empty' ~/.grid-credentials +``` + +If empty, stop and tell the user to add `solanaDevnetPrivateKey` (base58-encoded 64-byte keypair) to `~/.grid-credentials`. + +### 3. Install Python dependencies + +```bash +pip3 install solders solana base58 2>&1 | tail -5 +``` + +### 4. Set helper alias + +```bash +SOLANA_HELPER="python3 $(pwd)/.claude/skills/grid-usdc-sandbox/solana_helper.py" +``` + +### 5. Check SOL balance and airdrop if needed + +```bash +$SOLANA_HELPER sol-balance +``` + +If `sol` < 0.1, airdrop: + +```bash +$SOLANA_HELPER airdrop-sol --amount 1000000000 +``` + +### 6. Check USDC balance + +```bash +$SOLANA_HELPER usdc-balance +``` + +If `amount` < 1.0 USDC, warn the user that some tests may fail due to insufficient devnet USDC. Print instructions for obtaining devnet USDC (e.g., Solana devnet USDC faucet or manual transfer). + +### 7. Print wallet address + +```bash +$SOLANA_HELPER wallet-address +``` + +Save the address as `$WALLET_ADDRESS` for use in test cases. + +--- + +## Test Cases + +Run tests sequentially. Each test may depend on state created by prior tests. Track results for the final summary table. + +--- + +### Test 1: Customer + USDC Account Creation + +**Goal:** Create a customer and verify USDC internal account with funding instructions. + +**Steps:** + +1. Create a customer with a unique `platformCustomerId`: + +```bash +PLATFORM_CUSTOMER_ID="usdc-test-$(date +%s)" +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerType\": \"INDIVIDUAL\", + \"platformCustomerId\": \"$PLATFORM_CUSTOMER_ID\", + \"fullName\": \"USDC Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"US\" + }" \ + "$GRID_BASE_URL/customers" +``` + +Extract and save `CUSTOMER_ID` from the response `id` field. + +2. List internal accounts: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID&limit=100" +``` + +3. From the response, extract: + - `USDC_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USDC` + - `USD_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USD` + - `DEPOSIT_ADDRESS`: from the USDC account's `fundingPaymentInstructions` array, find the entry where `accountOrWalletInfo.accountType` is `SOLANA_WALLET` and extract `accountOrWalletInfo.address` + +4. **PASS criteria:** + - Customer created successfully + - USDC internal account exists + - `fundingPaymentInstructions` contains a `SOLANA_WALLET` entry with a non-empty `address` + +--- + +### Test 2: Fund Internal Account with Real Devnet USDC + +**Goal:** Send real USDC on devnet and verify Grid detects the deposit. + +**Steps:** + +1. Record initial USDC balance from internal account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +Save initial `balance.amount` as `INITIAL_USDC_BALANCE`. + +2. Send 0.50 USDC to the deposit address: + +```bash +$SOLANA_HELPER send-usdc --to $DEPOSIT_ADDRESS --amount 500000 +``` + +Verify the send was confirmed (status = "confirmed"). + +3. Poll for balance update every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +4. **PASS criteria:** USDC internal account `balance.amount` increases above `INITIAL_USDC_BALANCE`. + +--- + +### Test 3: Transfer Out (USDC internal → external Solana wallet) + +**Goal:** Withdraw USDC from internal account to an external Solana devnet wallet. + +**Steps:** + +1. Create an external account for our wallet: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"SOLANA_DEVNET\", + \"accountInfo\": { + \"accountType\": \"SOLANA_WALLET\", + \"address\": \"$WALLET_ADDRESS\" + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `USDC_EXTERNAL_ID`. + +2. Record initial on-chain USDC balance: + +```bash +$SOLANA_HELPER usdc-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_USDC`. + +3. Transfer out 0.10 USDC: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": {\"accountId\": \"$USDC_INTERNAL_ID\"}, + \"destination\": {\"accountId\": \"$USDC_EXTERNAL_ID\"}, + \"amount\": 100000 + }" \ + "$GRID_BASE_URL/transfer-out" +``` + +4. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: + +```bash +$SOLANA_HELPER usdc-balance +``` + +5. **PASS criteria:** On-chain USDC balance (`raw`) increases by approximately 100000 (0.10 USDC) from `INITIAL_ONCHAIN_USDC`. + +--- + +### Test 4: USDC → USD Quote (Real-Time Funded → internal USD account) + +**Goal:** Use real-time funding to convert USDC to USD via a JIT quote, depositing into the customer's internal USD account. + +**Steps:** + +1. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"SOLANA_DEVNET\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +2. Extract from the response: + - `QUOTE_ID`: the `id` field + - `TRANSACTION_ID`: the `transactionId` field + - `PAYMENT_ADDRESS`: from `paymentInstructions`, find the `SOLANA_WALLET` entry and extract `accountOrWalletInfo.address` + - `TOTAL_SENDING_AMOUNT`: the `totalSendingAmount` field (this is the micro-USDC amount to send) + +3. Record initial USD internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +Save `balance.amount` as `INITIAL_USD_BALANCE`. + +4. Send USDC to the payment instructions address: + +```bash +$SOLANA_HELPER send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll USD internal account balance every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +6. **PASS criteria:** USD internal account `balance.amount` increases above `INITIAL_USD_BALANCE`. + +--- + +### Test 5: USDC → USD Quote (Real-Time Funded → external USD bank account) + +**Goal:** Convert USDC to USD and send to an external bank account via ACH. + +**Steps:** + +1. Create an external USD bank account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USD\", + \"accountInfo\": { + \"accountType\": \"USD_ACCOUNT\", + \"paymentRails\": [\"ACH\"], + \"routingNumber\": \"021000021\", + \"accountNumber\": \"123456789012\", + \"accountCategory\": \"CHECKING\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"USDC Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"US\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `USD_EXTERNAL_ID`. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"SOLANA_DEVNET\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_EXTERNAL_ID\", + \"paymentRail\": \"ACH\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT` as in Test 4. + +4. Send USDC to the payment instructions address: + +```bash +$SOLANA_HELPER send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +### Test 6: USDC → MXN Quote (Real-Time Funded → external MXN CLABE account) + +**Goal:** Convert USDC to MXN and send to a Mexican bank account via SPEI. + +**Steps:** + +1. Create an external MXN account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"MXN\", + \"accountInfo\": { + \"accountType\": \"MXN_ACCOUNT\", + \"paymentRails\": [\"SPEI\"], + \"clabeNumber\": \"032180000118359719\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"USDC Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"MX\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `MXN_EXTERNAL_ID`. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"SOLANA_DEVNET\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$MXN_EXTERNAL_ID\", + \"paymentRail\": \"SPEI\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 200, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Note: `lockedCurrencyAmount: 200` = 2.00 MXN (smallest unit = centavos), roughly ~$0.10 USD. + +3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT`. + +4. Send USDC: + +```bash +$SOLANA_HELPER send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +### Test 7: USD → USDC Quote (Account-Funded → external Solana wallet) + +**Goal:** Convert USD from internal account to USDC delivered to our external Solana devnet wallet. + +**Steps:** + +1. Fund the USD internal account via sandbox endpoint: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d '{"amount": 100}' \ + "$GRID_BASE_URL/sandbox/internal-accounts/$USD_INTERNAL_ID/fund" +``` + +Verify the balance increased (response contains updated account). + +2. Record initial on-chain USDC balance: + +```bash +$SOLANA_HELPER usdc-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_USDC_T7`. + +3. Ensure USDC external account exists (reuse `$USDC_EXTERNAL_ID` from Test 3). If Test 3 was skipped, create it now. + +4. Create an account-funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USDC_EXTERNAL_ID\" + }, + \"lockedCurrencySide\": \"SENDING\", + \"lockedCurrencyAmount\": 50, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Save `QUOTE_ID` and `TRANSACTION_ID` from the response. + +5. Execute the quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST \ + "$GRID_BASE_URL/quotes/$QUOTE_ID/execute" +``` + +6. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: + +```bash +$SOLANA_HELPER usdc-balance +``` + +7. **PASS criteria:** On-chain USDC balance (`raw`) increases above `INITIAL_ONCHAIN_USDC_T7`. + +--- + +## Results Summary + +After all tests complete, print a final results table: + +``` +| # | Test Case | Status | Details | +|---|----------------------------------------|--------|---------| +| 1 | Customer + USDC Account Creation | PASS/FAIL | ... | +| 2 | Fund Internal Account (devnet USDC) | PASS/FAIL | ... | +| 3 | Transfer Out (USDC → Solana wallet) | PASS/FAIL | ... | +| 4 | USDC → USD (RT funded → internal) | PASS/FAIL | ... | +| 5 | USDC → USD (RT funded → external bank) | PASS/FAIL | ... | +| 6 | USDC → MXN (RT funded → CLABE) | PASS/FAIL | ... | +| 7 | USD → USDC (Account funded → wallet) | PASS/FAIL | ... | +``` + +Include in Details: relevant amounts, transaction IDs, error messages, or timing info. + +## Error Handling + +- If a test fails, record the failure and continue to the next test (do not abort the entire suite). +- If a polling loop times out, record FAIL with "timeout after 120s" and the last observed state. +- If the `send-usdc` command fails, check SOL balance (may need airdrop for gas) and USDC balance (may be insufficient). +- If a quote returns an error about `totalSendingAmount` being too small or too large, adjust the `lockedCurrencyAmount` and retry once. +- Common API errors: + - `USER_NOT_FOUND`: sandbox VASP may not have the required user — note in results + - `INSUFFICIENT_BALANCE`: the internal account doesn't have enough funds — note in results + - `QUOTE_EXPIRED`: quote expired before funding — retry with faster execution + +## Amounts Reference + +All tests use small amounts to conserve devnet funds: +- Test 2: 0.50 USDC deposit (500000 micro-USDC) +- Test 3: 0.10 USDC transfer-out (100000 micro-USDC) +- Tests 4-5: ~$0.10 USD locked on receiving side (10 cents) +- Test 6: ~2.00 MXN locked on receiving side (~$0.10 USD) +- Test 7: $0.50 USD → USDC (50 cents) +- **Total USDC needed: ~1.0 USDC + gas (~0.01 SOL)** diff --git a/.claude/skills/grid-usdc-sandbox/solana_helper.py b/.claude/skills/grid-usdc-sandbox/solana_helper.py new file mode 100644 index 00000000..6906076d --- /dev/null +++ b/.claude/skills/grid-usdc-sandbox/solana_helper.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Solana devnet CLI for Grid USDC sandbox testing. + +Subcommands: + wallet-address Print public key of loaded devnet keypair + sol-balance [--address] Print SOL balance + usdc-balance [--address] Print USDC balance (raw + human-readable) + send-usdc --to --amount Send USDC on devnet (amount in micro-USDC) + airdrop-sol [--amount] Request devnet SOL airdrop +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + import base58 + from solders.keypair import Keypair + from solders.pubkey import Pubkey + from solders.system_program import ID as SYS_PROGRAM_ID + from solders.transaction import Transaction + from solders.message import Message + from solders.instruction import Instruction, AccountMeta + from solders.hash import Hash + from solana.rpc.api import Client + from solana.rpc.commitment import Confirmed, Finalized + from solana.rpc.types import TxOpts + import struct +except ImportError as e: + print(json.dumps({"error": "Missing dependencies. Install with: pip3 install solders solana base58", "detail": str(e)})) + sys.exit(1) + +DEVNET_RPC = "https://api.devnet.solana.com" +DEVNET_USDC_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" +TOKEN_PROGRAM_ID = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") +ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") +USDC_DECIMALS = 6 + + +def load_keypair(creds_path=None): + creds_path = creds_path or os.path.expanduser("~/.grid-credentials") + with open(creds_path) as f: + creds = json.load(f) + secret_key = creds.get("solanaDevnetPrivateKey") + if not secret_key: + print(json.dumps({"error": "solanaDevnetPrivateKey not found in ~/.grid-credentials"})) + sys.exit(1) + raw = base58.b58decode(secret_key) + if len(raw) == 32: + return Keypair.from_seed(raw) + return Keypair.from_bytes(raw) + + +def get_client(): + return Client(DEVNET_RPC) + + +def get_ata(owner, mint): + seeds = [bytes(owner), bytes(TOKEN_PROGRAM_ID), bytes(mint)] + ata, _bump = Pubkey.find_program_address(seeds, ASSOCIATED_TOKEN_PROGRAM_ID) + return ata + + +def get_token_balance(client, address, mint_str): + mint = Pubkey.from_string(mint_str) + ata = get_ata(address, mint) + try: + resp = client.get_token_account_balance(ata) + except Exception: + return 0, "0" + if resp.value is None: + return 0, "0" + return int(resp.value.amount), resp.value.ui_amount_string + + +def cmd_wallet_address(args): + kp = load_keypair() + print(json.dumps({"address": str(kp.pubkey())})) + + +def cmd_sol_balance(args): + client = get_client() + if args.address: + pubkey = Pubkey.from_string(args.address) + else: + kp = load_keypair() + pubkey = kp.pubkey() + resp = client.get_balance(pubkey, commitment=Confirmed) + lamports = resp.value + print(json.dumps({ + "address": str(pubkey), + "lamports": lamports, + "sol": lamports / 1e9 + })) + + +def cmd_usdc_balance(args): + client = get_client() + mint_str = args.mint or DEVNET_USDC_MINT + if args.address: + pubkey = Pubkey.from_string(args.address) + else: + kp = load_keypair() + pubkey = kp.pubkey() + raw_amount, ui_amount = get_token_balance(client, pubkey, mint_str) + print(json.dumps({ + "address": str(pubkey), + "mint": mint_str, + "raw": raw_amount, + "amount": raw_amount / (10 ** USDC_DECIMALS), + "ui_amount": ui_amount + })) + + +def cmd_send_usdc(args): + kp = load_keypair() + client = get_client() + mint_str = args.mint or DEVNET_USDC_MINT + mint = Pubkey.from_string(mint_str) + recipient = Pubkey.from_string(args.to) + amount = int(args.amount) + + sender_ata = get_ata(kp.pubkey(), mint) + recipient_ata = get_ata(recipient, mint) + + instructions = [] + + recipient_ata_info = client.get_account_info(recipient_ata) + if recipient_ata_info.value is None: + create_ata_ix = Instruction( + program_id=ASSOCIATED_TOKEN_PROGRAM_ID, + accounts=[ + AccountMeta(pubkey=kp.pubkey(), is_signer=True, is_writable=True), + AccountMeta(pubkey=recipient_ata, is_signer=False, is_writable=True), + AccountMeta(pubkey=recipient, is_signer=False, is_writable=False), + AccountMeta(pubkey=mint, is_signer=False, is_writable=False), + AccountMeta(pubkey=SYS_PROGRAM_ID, is_signer=False, is_writable=False), + AccountMeta(pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False), + ], + data=bytes(), + ) + instructions.append(create_ata_ix) + + transfer_data = bytearray([12]) + transfer_data.extend(struct.pack(" Date: Thu, 26 Mar 2026 09:46:52 -0700 Subject: [PATCH 2/7] Adding base tests --- .../skills/grid-base-usdc-sandbox/SKILL.md | 537 ++++++++++++++++++ .../grid-base-usdc-sandbox/base_helper.py | 185 ++++++ 2 files changed, 722 insertions(+) create mode 100644 .claude/skills/grid-base-usdc-sandbox/SKILL.md create mode 100644 .claude/skills/grid-base-usdc-sandbox/base_helper.py diff --git a/.claude/skills/grid-base-usdc-sandbox/SKILL.md b/.claude/skills/grid-base-usdc-sandbox/SKILL.md new file mode 100644 index 00000000..2629d3c0 --- /dev/null +++ b/.claude/skills/grid-base-usdc-sandbox/SKILL.md @@ -0,0 +1,537 @@ +--- +name: grid-base-usdc-sandbox +description: > + End-to-end Base USDC sandbox flow tests using real Base Sepolia testnet funds. Use when the user asks to + "test Base USDC flows", "run Base sandbox tests", "test Base deposits and withdrawals", "test Base USDC sandbox", + "run e2e Base USDC test", "test Base realtime funding", "test Base USDC to USD", "test Base USDC to MXN", + or wants to verify Grid's USDC deposit/withdrawal/quote pipeline on Base testnet. +allowed-tools: + - Bash + - Read + - Grep + - Glob + - WebFetch +--- + +# Grid Base USDC Sandbox Flow Test + +End-to-end test of USDC sandbox flows on Base Sepolia: deposits, withdrawals, and cross-currency quotes using real Base testnet funds. + +## Prerequisites + +Run these steps before any tests. Stop and report if any step fails. + +### 1. Load Grid API credentials + +```bash +export GRID_API_TOKEN_ID=$(jq -r .apiTokenId ~/.grid-credentials) +export GRID_API_CLIENT_SECRET=$(jq -r .apiClientSecret ~/.grid-credentials) +export GRID_BASE_URL=$(jq -r '.baseUrl // "https://api.lightspark.com/grid/2025-10-13"' ~/.grid-credentials) +``` + +### 2. Verify Base testnet key exists + +```bash +jq -r '.baseTestnetPrivateKey // empty' ~/.grid-credentials +``` + +If empty, stop and tell the user to add `baseTestnetPrivateKey` (hex-encoded Ethereum private key, with or without `0x` prefix) to `~/.grid-credentials`. + +### 3. Install Python dependencies + +```bash +pip3 install web3 2>&1 | tail -5 +``` + +### 4. Set helper alias + +```bash +BASE_HELPER="python3 $(pwd)/.claude/skills/grid-base-usdc-sandbox/base_helper.py" +``` + +### 5. Check ETH balance (gas) + +```bash +$BASE_HELPER eth-balance +``` + +If `eth` < 0.001, warn the user that they need Base Sepolia ETH for gas. They can obtain it from: +- https://www.alchemy.com/faucets/base-sepolia +- https://faucet.quicknode.com/base/sepolia + +### 6. Check USDC balance + +```bash +$BASE_HELPER usdc-balance +``` + +If `amount` < 1.0 USDC, warn the user that some tests may fail due to insufficient testnet USDC. The Base Sepolia USDC contract is `0x036CbD53842c5426634e7929541eC2318f3dCF7e` — they can obtain testnet USDC from Circle's testnet faucet at https://faucet.circle.com/ (select Base Sepolia). + +### 7. Print wallet address + +```bash +$BASE_HELPER wallet-address +``` + +Save the address as `$WALLET_ADDRESS` for use in test cases. + +--- + +## Test Cases + +Run tests sequentially. Each test may depend on state created by prior tests. Track results for the final summary table. + +--- + +### Test 1: Customer + USDC Account Creation + +**Goal:** Create a customer and verify USDC internal account with Base wallet funding instructions. + +**Steps:** + +1. Create a customer with a unique `platformCustomerId`: + +```bash +PLATFORM_CUSTOMER_ID="base-usdc-test-$(date +%s)" +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerType\": \"INDIVIDUAL\", + \"platformCustomerId\": \"$PLATFORM_CUSTOMER_ID\", + \"fullName\": \"Base USDC Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"US\" + }" \ + "$GRID_BASE_URL/customers" +``` + +Extract and save `CUSTOMER_ID` from the response `id` field. + +2. List internal accounts: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID&limit=100" +``` + +3. From the response, extract: + - `USDC_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USDC` + - `USD_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USD` + - `DEPOSIT_ADDRESS`: from the USDC account's `fundingPaymentInstructions` array, find the entry where `accountOrWalletInfo.accountType` is `BASE_WALLET` and extract `accountOrWalletInfo.address` + +4. **PASS criteria:** + - Customer created successfully + - USDC internal account exists + - `fundingPaymentInstructions` contains a `BASE_WALLET` entry with a non-empty `address` + +--- + +### Test 2: Fund Internal Account with Real Testnet USDC + +**Goal:** Send real USDC on Base Sepolia and verify Grid detects the deposit. + +**Steps:** + +1. Record initial USDC balance from internal account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +Save initial `balance.amount` as `INITIAL_USDC_BALANCE`. + +2. Send 0.50 USDC to the deposit address: + +```bash +$BASE_HELPER send-usdc --to $DEPOSIT_ADDRESS --amount 500000 +``` + +Verify the send was confirmed (status = "confirmed"). + +3. Poll for balance update every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +4. **PASS criteria:** USDC internal account `balance.amount` increases above `INITIAL_USDC_BALANCE`. + +--- + +### Test 3: Transfer Out (USDC internal → external Base wallet) + +**Goal:** Withdraw USDC from internal account to an external Base Sepolia wallet. + +**Steps:** + +1. Create an external account for our wallet: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"BASE_TESTNET\", + \"accountInfo\": { + \"accountType\": \"BASE_WALLET\", + \"address\": \"$WALLET_ADDRESS\" + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `USDC_EXTERNAL_ID`. + +2. Record initial on-chain USDC balance: + +```bash +$BASE_HELPER usdc-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_USDC`. + +3. Transfer out 0.10 USDC: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": {\"accountId\": \"$USDC_INTERNAL_ID\"}, + \"destination\": {\"accountId\": \"$USDC_EXTERNAL_ID\"}, + \"amount\": 100000 + }" \ + "$GRID_BASE_URL/transfer-out" +``` + +4. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: + +```bash +$BASE_HELPER usdc-balance +``` + +5. **PASS criteria:** On-chain USDC balance (`raw`) increases by approximately 100000 (0.10 USDC) from `INITIAL_ONCHAIN_USDC`. + +--- + +### Test 4: USDC → USD Quote (Real-Time Funded → internal USD account) + +**Goal:** Use real-time funding to convert USDC to USD via a JIT quote, depositing into the customer's internal USD account. + +**Steps:** + +1. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"BASE_TESTNET\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +2. Extract from the response: + - `QUOTE_ID`: the `id` field + - `TRANSACTION_ID`: the `transactionId` field + - `PAYMENT_ADDRESS`: from `paymentInstructions`, find the `BASE_WALLET` entry and extract `accountOrWalletInfo.address` + - `TOTAL_SENDING_AMOUNT`: the `totalSendingAmount` field (this is the micro-USDC amount to send) + +3. Record initial USD internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +Save `balance.amount` as `INITIAL_USD_BALANCE`. + +4. Send USDC to the payment instructions address: + +```bash +$BASE_HELPER send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll USD internal account balance every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +6. **PASS criteria:** USD internal account `balance.amount` increases above `INITIAL_USD_BALANCE`. + +--- + +### Test 5: USDC → USD Quote (Real-Time Funded → external USD bank account) + +**Goal:** Convert USDC to USD and send to an external bank account via ACH. + +**Steps:** + +1. Create an external USD bank account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USD\", + \"accountInfo\": { + \"accountType\": \"USD_ACCOUNT\", + \"paymentRails\": [\"ACH\"], + \"routingNumber\": \"021000021\", + \"accountNumber\": \"123456789012\", + \"accountCategory\": \"CHECKING\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"Base USDC Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"US\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `USD_EXTERNAL_ID`. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"BASE_TESTNET\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_EXTERNAL_ID\", + \"paymentRail\": \"ACH\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT` as in Test 4. + +4. Send USDC to the payment instructions address: + +```bash +$BASE_HELPER send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +### Test 6: USDC → MXN Quote (Real-Time Funded → external MXN CLABE account) + +**Goal:** Convert USDC to MXN and send to a Mexican bank account via SPEI. + +**Steps:** + +1. Create an external MXN account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"MXN\", + \"accountInfo\": { + \"accountType\": \"MXN_ACCOUNT\", + \"paymentRails\": [\"SPEI\"], + \"clabeNumber\": \"032180000118359719\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"Base USDC Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"MX\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `MXN_EXTERNAL_ID`. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"BASE_TESTNET\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$MXN_EXTERNAL_ID\", + \"paymentRail\": \"SPEI\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 200, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Note: `lockedCurrencyAmount: 200` = 2.00 MXN (smallest unit = centavos), roughly ~$0.10 USD. + +3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT`. + +4. Send USDC: + +```bash +$BASE_HELPER send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +### Test 7: USD → USDC Quote (Account-Funded → external Base wallet) + +**Goal:** Convert USD from internal account to USDC delivered to our external Base Sepolia wallet. + +**Steps:** + +1. Fund the USD internal account via sandbox endpoint: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d '{"amount": 100}' \ + "$GRID_BASE_URL/sandbox/internal-accounts/$USD_INTERNAL_ID/fund" +``` + +Verify the balance increased (response contains updated account). + +2. Record initial on-chain USDC balance: + +```bash +$BASE_HELPER usdc-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_USDC_T7`. + +3. Ensure USDC external account exists (reuse `$USDC_EXTERNAL_ID` from Test 3). If Test 3 was skipped, create it now. + +4. Create an account-funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USDC_EXTERNAL_ID\" + }, + \"lockedCurrencySide\": \"SENDING\", + \"lockedCurrencyAmount\": 50, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Save `QUOTE_ID` and `TRANSACTION_ID` from the response. + +5. Execute the quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST \ + "$GRID_BASE_URL/quotes/$QUOTE_ID/execute" +``` + +6. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: + +```bash +$BASE_HELPER usdc-balance +``` + +7. **PASS criteria:** On-chain USDC balance (`raw`) increases above `INITIAL_ONCHAIN_USDC_T7`. + +--- + +## Results Summary + +After all tests complete, print a final results table: + +``` +| # | Test Case | Status | Details | +|---|----------------------------------------|--------|---------| +| 1 | Customer + USDC Account Creation | PASS/FAIL | ... | +| 2 | Fund Internal Account (Base USDC) | PASS/FAIL | ... | +| 3 | Transfer Out (USDC → Base wallet) | PASS/FAIL | ... | +| 4 | USDC → USD (RT funded → internal) | PASS/FAIL | ... | +| 5 | USDC → USD (RT funded → external bank) | PASS/FAIL | ... | +| 6 | USDC → MXN (RT funded → CLABE) | PASS/FAIL | ... | +| 7 | USD → USDC (Account funded → wallet) | PASS/FAIL | ... | +``` + +Include in Details: relevant amounts, transaction IDs, error messages, or timing info. + +## Error Handling + +- If a test fails, record the failure and continue to the next test (do not abort the entire suite). +- If a polling loop times out, record FAIL with "timeout after 120s" and the last observed state. +- If the `send-usdc` command fails, check ETH balance (may need testnet ETH for gas) and USDC balance (may be insufficient). +- If a quote returns an error about `totalSendingAmount` being too small or too large, adjust the `lockedCurrencyAmount` and retry once. +- Common API errors: + - `USER_NOT_FOUND`: sandbox VASP may not have the required user — note in results + - `INSUFFICIENT_BALANCE`: the internal account doesn't have enough funds — note in results + - `QUOTE_EXPIRED`: quote expired before funding — retry with faster execution + +## Amounts Reference + +All tests use small amounts to conserve testnet funds: +- Test 2: 0.50 USDC deposit (500000 micro-USDC) +- Test 3: 0.10 USDC transfer-out (100000 micro-USDC) +- Tests 4-5: ~$0.10 USD locked on receiving side (10 cents) +- Test 6: ~2.00 MXN locked on receiving side (~$0.10 USD) +- Test 7: $0.50 USD → USDC (50 cents) +- **Total USDC needed: ~1.0 USDC + gas (~0.001 ETH on Base Sepolia)** diff --git a/.claude/skills/grid-base-usdc-sandbox/base_helper.py b/.claude/skills/grid-base-usdc-sandbox/base_helper.py new file mode 100644 index 00000000..f856a57d --- /dev/null +++ b/.claude/skills/grid-base-usdc-sandbox/base_helper.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Base testnet CLI for Grid USDC sandbox testing. + +Subcommands: + wallet-address Print public address of loaded testnet key + eth-balance [--address] Print ETH balance on Base Sepolia + usdc-balance [--address] Print USDC balance (raw + human-readable) + send-usdc --to --amount Send USDC on Base Sepolia (amount in micro-USDC, 6 decimals) +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + from web3 import Web3 + from eth_account import Account +except ImportError as e: + print(json.dumps({"error": "Missing dependencies. Install with: pip3 install web3", "detail": str(e)})) + sys.exit(1) + +BASE_SEPOLIA_RPC = "https://sepolia.base.org" +BASE_SEPOLIA_CHAIN_ID = 84532 +USDC_CONTRACT = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +USDC_DECIMALS = 6 + +ERC20_ABI = [ + { + "constant": True, + "inputs": [{"name": "_owner", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "balance", "type": "uint256"}], + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_to", "type": "address"}, + {"name": "_value", "type": "uint256"}, + ], + "name": "transfer", + "outputs": [{"name": "", "type": "bool"}], + "type": "function", + }, +] + + +def load_account(creds_path=None): + creds_path = creds_path or os.path.expanduser("~/.grid-credentials") + with open(creds_path) as f: + creds = json.load(f) + private_key = creds.get("baseTestnetPrivateKey") + if not private_key: + print(json.dumps({"error": "baseTestnetPrivateKey not found in ~/.grid-credentials"})) + sys.exit(1) + if not private_key.startswith("0x"): + private_key = "0x" + private_key + return Account.from_key(private_key) + + +def get_web3(): + w3 = Web3(Web3.HTTPProvider(BASE_SEPOLIA_RPC)) + if not w3.is_connected(): + print(json.dumps({"error": "Failed to connect to Base Sepolia RPC", "rpc": BASE_SEPOLIA_RPC})) + sys.exit(1) + return w3 + + +def get_usdc_contract(w3): + return w3.eth.contract(address=Web3.to_checksum_address(USDC_CONTRACT), abi=ERC20_ABI) + + +def cmd_wallet_address(args): + acct = load_account() + print(json.dumps({"address": acct.address})) + + +def cmd_eth_balance(args): + w3 = get_web3() + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + balance_wei = w3.eth.get_balance(address) + print(json.dumps({ + "address": address, + "wei": balance_wei, + "eth": float(Web3.from_wei(balance_wei, "ether")), + })) + + +def cmd_usdc_balance(args): + w3 = get_web3() + usdc = get_usdc_contract(w3) + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + raw = usdc.functions.balanceOf(address).call() + print(json.dumps({ + "address": address, + "contract": USDC_CONTRACT, + "raw": raw, + "amount": raw / (10 ** USDC_DECIMALS), + "ui_amount": f"{raw / (10 ** USDC_DECIMALS):.6f}", + })) + + +def cmd_send_usdc(args): + acct = load_account() + w3 = get_web3() + usdc = get_usdc_contract(w3) + recipient = Web3.to_checksum_address(args.to) + amount = int(args.amount) + + nonce = w3.eth.get_transaction_count(acct.address) + + tx = usdc.functions.transfer(recipient, amount).build_transaction({ + "chainId": BASE_SEPOLIA_CHAIN_ID, + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": w3.eth.gas_price * 2, + "maxPriorityFeePerGas": w3.to_wei(0.001, "gwei"), + }) + + signed = acct.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash_hex = tx_hash.hex() + + print(json.dumps({"status": "sent", "tx_hash": tx_hash_hex, "message": "Waiting for confirmation..."})) + + for _ in range(60): + time.sleep(2) + try: + receipt = w3.eth.get_transaction_receipt(tx_hash) + if receipt is not None: + if receipt["status"] == 1: + print(json.dumps({"status": "confirmed", "tx_hash": tx_hash_hex, "block": receipt["blockNumber"]})) + return + else: + print(json.dumps({"status": "failed", "tx_hash": tx_hash_hex, "receipt_status": receipt["status"]})) + sys.exit(1) + except Exception: + pass + + print(json.dumps({"status": "timeout", "tx_hash": tx_hash_hex, "message": "Transaction sent but confirmation timed out."})) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="Base Sepolia helper for Grid USDC sandbox testing") + sub = parser.add_subparsers(dest="command") + sub.required = True + + sub.add_parser("wallet-address", help="Print public address of loaded testnet key") + + eth_bal = sub.add_parser("eth-balance", help="Print ETH balance on Base Sepolia") + eth_bal.add_argument("--address", help="Address to check (default: own wallet)") + + usdc_bal = sub.add_parser("usdc-balance", help="Print USDC balance on Base Sepolia") + usdc_bal.add_argument("--address", help="Address to check (default: own wallet)") + + send = sub.add_parser("send-usdc", help="Send USDC on Base Sepolia") + send.add_argument("--to", required=True, help="Recipient address (0x...)") + send.add_argument("--amount", required=True, help="Amount in micro-USDC (6 decimals)") + + args = parser.parse_args() + + dispatch = { + "wallet-address": cmd_wallet_address, + "eth-balance": cmd_eth_balance, + "usdc-balance": cmd_usdc_balance, + "send-usdc": cmd_send_usdc, + } + dispatch[args.command](args) + + +if __name__ == "__main__": + main() From 567c13bca565b10f1e4fe3194e4bc002f64801e3 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 8 Apr 2026 09:56:37 -0700 Subject: [PATCH 3/7] Support non-sandbox for usdc base --- .../skills/grid-base-usdc-sandbox/SKILL.md | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/.claude/skills/grid-base-usdc-sandbox/SKILL.md b/.claude/skills/grid-base-usdc-sandbox/SKILL.md index 2629d3c0..862b0b71 100644 --- a/.claude/skills/grid-base-usdc-sandbox/SKILL.md +++ b/.claude/skills/grid-base-usdc-sandbox/SKILL.md @@ -29,6 +29,22 @@ export GRID_API_CLIENT_SECRET=$(jq -r .apiClientSecret ~/.grid-credentials) export GRID_BASE_URL=$(jq -r '.baseUrl // "https://api.lightspark.com/grid/2025-10-13"' ~/.grid-credentials) ``` +### 1b. Detect sandbox vs non-sandbox platform + +Try a sandbox endpoint to determine platform type. Save the result for use in tests that have sandbox-specific behavior. + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d '{"amount": 0}' \ + "$GRID_BASE_URL/sandbox/internal-accounts/dummy/fund" +``` + +- If the response contains `"not a sandbox platform"`, set `IS_SANDBOX=false` +- Otherwise (any other error like "not found", or success), set `IS_SANDBOX=true` + +Report the detected mode to the user (e.g., "Detected non-sandbox platform" or "Detected sandbox platform"). + ### 2. Verify Base testnet key exists ```bash @@ -193,7 +209,7 @@ $BASE_HELPER usdc-balance Save `raw` as `INITIAL_ONCHAIN_USDC`. -3. Transfer out 0.10 USDC: +3. Transfer out 0.20 USDC: ```bash curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ @@ -201,18 +217,20 @@ curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ -d "{ \"source\": {\"accountId\": \"$USDC_INTERNAL_ID\"}, \"destination\": {\"accountId\": \"$USDC_EXTERNAL_ID\"}, - \"amount\": 100000 + \"amount\": 200000 }" \ "$GRID_BASE_URL/transfer-out" ``` +Note: the amount must exceed the custody provider fee (~100100 micro-USDC), so 200000 is the minimum safe amount. + 4. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: ```bash $BASE_HELPER usdc-balance ``` -5. **PASS criteria:** On-chain USDC balance (`raw`) increases by approximately 100000 (0.10 USDC) from `INITIAL_ONCHAIN_USDC`. +5. **PASS criteria:** On-chain USDC balance (`raw`) increases above `INITIAL_ONCHAIN_USDC` (net amount will be ~99900 after fees). --- @@ -398,8 +416,7 @@ curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ }, \"destination\": { \"destinationType\": \"ACCOUNT\", - \"accountId\": \"$MXN_EXTERNAL_ID\", - \"paymentRail\": \"SPEI\" + \"accountId\": \"$MXN_EXTERNAL_ID\" }, \"lockedCurrencySide\": \"RECEIVING\", \"lockedCurrencyAmount\": 200, @@ -408,7 +425,7 @@ curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ "$GRID_BASE_URL/quotes" ``` -Note: `lockedCurrencyAmount: 200` = 2.00 MXN (smallest unit = centavos), roughly ~$0.10 USD. +Note: `lockedCurrencyAmount: 200` = 2.00 MXN (smallest unit = centavos), roughly ~$0.10 USD. Do not include `paymentRail` — the API infers it from the external account. 3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT`. @@ -435,7 +452,9 @@ curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ **Steps:** -1. Fund the USD internal account via sandbox endpoint: +1. Fund the USD internal account: + +**If `IS_SANDBOX=true`:** Use the sandbox fund endpoint: ```bash curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ @@ -446,6 +465,8 @@ curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ Verify the balance increased (response contains updated account). +**If `IS_SANDBOX=false`:** Check the current USD internal account balance. If balance is 0, skip this test with a note: "SKIP: Non-sandbox platform — USD internal account has no balance. Requires a prior successful USDC→USD conversion (Test 4) or manual funding." If balance > 0, proceed. + 2. Record initial on-chain USDC balance: ```bash @@ -530,8 +551,8 @@ Include in Details: relevant amounts, transaction IDs, error messages, or timing All tests use small amounts to conserve testnet funds: - Test 2: 0.50 USDC deposit (500000 micro-USDC) -- Test 3: 0.10 USDC transfer-out (100000 micro-USDC) +- Test 3: 0.20 USDC transfer-out (200000 micro-USDC) — must exceed ~100100 custody fee - Tests 4-5: ~$0.10 USD locked on receiving side (10 cents) - Test 6: ~2.00 MXN locked on receiving side (~$0.10 USD) -- Test 7: $0.50 USD → USDC (50 cents) -- **Total USDC needed: ~1.0 USDC + gas (~0.001 ETH on Base Sepolia)** +- Test 7: $0.50 USD → USDC (50 cents) — requires sandbox or prior USD balance +- **Total USDC needed: ~1.2 USDC + gas (~0.001 ETH on Base Sepolia)** From 3af0133cda3a9c850c6ba6d040110f2c288ac5fd Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 8 Apr 2026 09:59:31 -0700 Subject: [PATCH 4/7] renames --- .../{grid-base-usdc-sandbox => grid-base-usdc-test}/SKILL.md | 0 .../base_helper.py | 0 .../{grid-usdc-sandbox => grid-solana-usdc-sandbox}/SKILL.md | 0 .../solana_helper.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename .claude/skills/{grid-base-usdc-sandbox => grid-base-usdc-test}/SKILL.md (100%) rename .claude/skills/{grid-base-usdc-sandbox => grid-base-usdc-test}/base_helper.py (100%) rename .claude/skills/{grid-usdc-sandbox => grid-solana-usdc-sandbox}/SKILL.md (100%) rename .claude/skills/{grid-usdc-sandbox => grid-solana-usdc-sandbox}/solana_helper.py (100%) diff --git a/.claude/skills/grid-base-usdc-sandbox/SKILL.md b/.claude/skills/grid-base-usdc-test/SKILL.md similarity index 100% rename from .claude/skills/grid-base-usdc-sandbox/SKILL.md rename to .claude/skills/grid-base-usdc-test/SKILL.md diff --git a/.claude/skills/grid-base-usdc-sandbox/base_helper.py b/.claude/skills/grid-base-usdc-test/base_helper.py similarity index 100% rename from .claude/skills/grid-base-usdc-sandbox/base_helper.py rename to .claude/skills/grid-base-usdc-test/base_helper.py diff --git a/.claude/skills/grid-usdc-sandbox/SKILL.md b/.claude/skills/grid-solana-usdc-sandbox/SKILL.md similarity index 100% rename from .claude/skills/grid-usdc-sandbox/SKILL.md rename to .claude/skills/grid-solana-usdc-sandbox/SKILL.md diff --git a/.claude/skills/grid-usdc-sandbox/solana_helper.py b/.claude/skills/grid-solana-usdc-sandbox/solana_helper.py similarity index 100% rename from .claude/skills/grid-usdc-sandbox/solana_helper.py rename to .claude/skills/grid-solana-usdc-sandbox/solana_helper.py From d1ac77fbe1936938c7cf1d9ce29efa2a24f1d3ec Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Sun, 12 Apr 2026 22:34:22 -0700 Subject: [PATCH 5/7] Add a generic grid-test skill that's more comprehensive over all quote paths --- .claude/skills/grid-test/SKILL.md | 274 +++++++ .../grid-test/references/test-catalog.md | 713 ++++++++++++++++++ .../skills/grid-test/scripts/base_helper.py | 209 +++++ .../grid-test/scripts/polygon_helper.py | 209 +++++ .../skills/grid-test/scripts/solana_helper.py | 276 +++++++ 5 files changed, 1681 insertions(+) create mode 100644 .claude/skills/grid-test/SKILL.md create mode 100644 .claude/skills/grid-test/references/test-catalog.md create mode 100644 .claude/skills/grid-test/scripts/base_helper.py create mode 100644 .claude/skills/grid-test/scripts/polygon_helper.py create mode 100644 .claude/skills/grid-test/scripts/solana_helper.py diff --git a/.claude/skills/grid-test/SKILL.md b/.claude/skills/grid-test/SKILL.md new file mode 100644 index 00000000..b30853ef --- /dev/null +++ b/.claude/skills/grid-test/SKILL.md @@ -0,0 +1,274 @@ +--- +name: grid-test +description: > + This skill should be used when the user asks to "test Grid", "run USDC tests", "test deposits", + "test withdrawals", "test Solana flows", "test Base flows", "test Polygon flows", "run e2e tests", + "test sandbox", "test USDC to USD", "test USDC to MXN", "run all Grid tests", "test transfer out", + "test realtime funding", "test quote flows", "test deposits and withdrawals", + "run sandbox tests", "test USDC sandbox", "test Grid API", "run e2e USDC test", + "test USDC on [chain]", or wants to verify Grid's USDC deposit/withdrawal/quote pipeline. + Even if the user mentions just one chain, one test, or one corridor, this skill applies. + This replaces both grid-solana-usdc-sandbox and grid-base-usdc-test. +allowed-tools: + - Bash + - Read + - Grep + - Glob + - WebFetch +--- + +# Grid API Test Suite + +End-to-end tests for USDC flows on Solana, Base, and Polygon: deposits, withdrawals, and cross-currency quotes using real testnet (or mainnet) funds. + +## Step 1: Parse the User's Prompt + +Determine what to run from the user's request: + +**Chains** (default: all available — see step 3 for which have keys): +- `solana`, `base`, `polygon`, or `all` +- Multiple chains: "test solana and base", "run base and polygon tests" + +**Tests** (default: all): +- By number: "run test 4 on solana" +- By name: "test deposits on base", "test USDC to MXN", "test transfer out" +- By category: "test all quote flows", "test RT funded flows", "test account-funded flows" + +**Test name → number mapping:** + +| # | Short Name | Keywords | +|---|-----------|----------| +| 1 | account-creation | customer, account, setup | +| 2 | deposit | deposit, fund, send USDC to Grid | +| 3 | transfer-out | withdraw, transfer out, send to wallet | +| 4 | usdc-to-usd-internal-rt | USDC→USD internal, RT funded internal | +| 5 | usdc-to-usd-bank-rt | USDC→USD bank, RT funded ACH, external bank | +| 6 | usdc-to-mxn-rt | USDC→MXN RT, SPEI, CLABE, Mexico RT | +| 7 | usd-to-usdc | USD→USDC, buy USDC, account funded wallet | +| 8 | usdc-to-usd-internal-acct | USDC→USD account funded, convert USDC balance | +| 9 | usdc-to-mxn-acct | USDC→MXN account funded, SPEI account funded | +| 10 | usdc-to-uma-rt | USDC→UMA RT, UMA realtime, send to UMA | +| 11 | usd-to-uma-acct | USD→UMA account funded, UMA payout | + +**Category shortcuts:** +- "quote flows" or "quotes" → tests 4-11 +- "RT funded" or "realtime" → tests 4-6, 10 +- "account funded" → tests 7-9, 11 +- "transfers" → tests 2-3 +- "UMA" → tests 10-11 + +## Step 2: Load Credentials + +```bash +export GRID_API_TOKEN_ID=$(jq -r .apiTokenId ~/.grid-credentials) +export GRID_API_CLIENT_SECRET=$(jq -r .apiClientSecret ~/.grid-credentials) +export GRID_BASE_URL=$(jq -r '.baseUrl // "https://api.lightspark.com/grid/2025-10-13"' ~/.grid-credentials) +``` + +## Step 3: Detect Environment + +### Sandbox vs non-sandbox + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d '{"amount": 1}' \ + "$GRID_BASE_URL/sandbox/internal-accounts/dummy/fund" +``` + +- Response contains `"not a sandbox platform"` → `IS_SANDBOX=false` +- Response contains `"not found"` or other non-platform error → `IS_SANDBOX=true` + +Use `amount: 1` (not 0) — a zero amount returns a validation error on both sandbox and non-sandbox, masking the real detection. + +Report the detected mode to the user. + +### Testnet vs mainnet + +Check `GRID_BASE_URL` and credential keys to determine network: +- If `IS_SANDBOX=true` or URL contains dev/staging → testnet networks +- If production URL + `IS_SANDBOX=false` → mainnet networks + +## Step 4: Set Up Each Selected Chain + +For each chain the user wants to test, set the chain-specific variables and verify prerequisites. + +### Chain Configuration Lookup + +**Testnet (sandbox/dev):** + +| Variable | Solana | Base | Polygon | +|---|---|---|---| +| `CRYPTO_NETWORK` | `SOLANA_DEVNET` | `BASE_TESTNET` | `POLYGON_TESTNET` | +| `WALLET_TYPE` | `SOLANA_WALLET` | `BASE_WALLET` | `POLYGON_WALLET` | +| `CRED_KEY` | `solanaDevnetPrivateKey` | `baseTestnetPrivateKey` | `polygonTestnetPrivateKey` | +| `HELPER_SCRIPT` | `scripts/solana_helper.py` | `scripts/base_helper.py` | `scripts/polygon_helper.py` | +| `GAS_CMD` | `sol-balance` | `eth-balance` | `pol-balance` | +| `GAS_TOKEN` | SOL | ETH | POL | +| `GAS_MIN` | 0.1 | 0.001 | 0.1 | +| `TRANSFER_OUT_AMT` | 100000 | 200000 | 200000 | +| `PIP_DEPS` | `solders solana base58` | `web3` | `web3` | + +**Mainnet (non-sandbox production):** + +| Variable | Solana | Base | Polygon | +|---|---|---|---| +| `CRYPTO_NETWORK` | `SOLANA_MAINNET` | `BASE_MAINNET` | `POLYGON_MAINNET` | +| `WALLET_TYPE` | `SOLANA_WALLET` | `BASE_WALLET` | `POLYGON_WALLET` | +| `CRED_KEY` | `solanaMainnetPrivateKey` | `baseMainnetPrivateKey` | `polygonMainnetPrivateKey` | +| Other vars | Same as testnet | Same as testnet | Same as testnet | + +### Per-chain prerequisites + +For each selected chain, run these checks. Skip a chain (with a warning) if its private key is missing. + +1. **Verify private key exists:** + ```bash + jq -r ".$CRED_KEY // empty" ~/.grid-credentials + ``` + If empty, warn the user and skip this chain. + +2. **Install dependencies:** + ```bash + pip3 install $PIP_DEPS 2>&1 | tail -5 + ``` + +3. **Define helper function** (pass `--mainnet` if running on mainnet): + ```bash + # Testnet: + chain_helper() { python3 /absolute/path/to/.claude/skills/grid-test/$HELPER_SCRIPT "$@"; } + # Mainnet: + chain_helper() { python3 /absolute/path/to/.claude/skills/grid-test/$HELPER_SCRIPT --mainnet "$@"; } + ``` + + Use a shell function (not a variable) so that arguments are word-split correctly. Then call as `chain_helper send-usdc --to ...`. All helper scripts accept `--mainnet` to switch RPC endpoints, chain IDs, USDC contract addresses, and credential keys automatically. + +4. **Check gas balance:** + ```bash + $CHAIN_HELPER $GAS_CMD + ``` + If below `GAS_MIN`, warn the user with instructions for obtaining testnet gas: + - Solana: `$CHAIN_HELPER airdrop-sol --amount 1000000000` + - Base: https://www.alchemy.com/faucets/base-sepolia + - Polygon: https://faucet.polygon.technology/ + +5. **Check USDC balance:** + ```bash + $CHAIN_HELPER usdc-balance + ``` + If `amount` < 1.0 USDC, warn the user. Testnet USDC sources: + - Solana: Solana devnet USDC faucet + - Base: https://faucet.circle.com/ (select Base Sepolia) + - Polygon: https://faucet.circle.com/ (select Polygon Amoy) + +6. **Get wallet address:** + ```bash + $CHAIN_HELPER wallet-address + ``` + Save as `WALLET_ADDRESS` for this chain. + +## Step 5: Run Tests + +Read `references/test-catalog.md` for detailed test steps. Each test is parameterized by chain variables set in Step 4. Run tests sequentially within each chain (later tests depend on state from earlier ones). + +**Dependency note:** If the user requests a specific test (e.g., test 4), also run its dependencies: +- Tests 2-11 depend on Test 1 (customer + account creation) +- Tests 3, 8, 9 depend on Test 2 (needs USDC in internal account) +- Tests 7, 11 need USD balance — either sandbox fund endpoint or a prior USDC→USD conversion (Test 4 or 8) +- Tests 10-11 need a valid UMA receiver address (defaults to `$test@sandbox.grid.uma.money`, overridable via `UMA_RECEIVER` env var) + +If running a subset, create the customer (Test 1) silently as setup, then run only the requested tests. + +**Multi-chain execution:** Run each chain fully before moving to the next. Set `CHAIN_PREFIX` per chain for unique customer IDs: +- Solana: `CHAIN_PREFIX="solana-test"` +- Base: `CHAIN_PREFIX="base-test"` +- Polygon: `CHAIN_PREFIX="polygon-test"` + +## Step 6: Results Summary + +After all tests complete, print a results table per chain: + +``` +## Solana Results +| # | Test Case | Status | Details | +|---|----------------------------------------|--------|---------| +| 1 | Customer + USDC Account Creation | PASS | ... | +| 2 | Fund Internal Account (deposit) | PASS | ... | +| 3 | Transfer Out (→ wallet) | PASS | ... | +| 4 | USDC → USD (RT funded → internal) | PASS | ... | +| 5 | USDC → USD (RT funded → external bank) | PASS | ... | +| 6 | USDC → MXN (RT funded → CLABE) | PASS | ... | +| 7 | USD → USDC (Account funded → wallet) | PASS | ... | +| 8 | USDC → USD (Account funded → internal) | PASS | ... | +| 9 | USDC → MXN (Account funded → CLABE) | PASS | ... | +| 10 | USDC → USD (RT funded → UMA) | PASS | ... | +| 11 | USD → USD (Account funded → UMA) | PASS | ... | + +## Base Results +... + +## Polygon Results +... +``` + +Include in Details: amounts, transaction IDs, error messages, or timing. + +If multiple chains were tested, add an aggregate summary: + +``` +## Summary +| Chain | Passed | Failed | Skipped | +|---------|--------|--------|---------| +| Solana | 7/7 | 0 | 0 | +| Base | 6/7 | 1 | 0 | +| Polygon | 0/7 | 0 | 7 | +``` + +## Error Handling + +- If a test fails, record the failure and continue to the next test. +- If a polling loop times out, record FAIL with "timeout after 120s" and the last observed state. +- If `send-usdc` fails, check gas balance (may need airdrop/faucet) and USDC balance. +- If a quote returns an error about `totalSendingAmount` being too small or too large, adjust `lockedCurrencyAmount` and retry once. +- Common API errors: + - `USER_NOT_FOUND`: sandbox VASP may not have the required user + - `INSUFFICIENT_BALANCE`: internal account doesn't have enough funds + - `QUOTE_EXPIRED`: quote expired before funding — retry with faster execution + +## Amounts Reference + +All tests use small amounts to conserve testnet funds: + +| Test | Amount | Notes | +|------|--------|-------| +| 2 (deposit) | 0.50 USDC (500000) | | +| 3 (transfer-out) | Solana: 0.10 USDC (100000), Base/Polygon: 0.20 USDC (200000) | Base/Polygon must exceed ~100100 custody fee | +| 4-5 (USDC→USD RT) | $0.10 locked receiving (10 cents) | | +| 6 (USDC→MXN RT) | 11.00 MXN locked receiving (1100 centavos, ~$0.55) | Some envs enforce 1100 minimum | +| 7 (USD→USDC) | $0.50 sending (50 cents) | Requires sandbox or prior USD balance | +| 8 (USDC→USD acct) | 0.05 USDC sending (50000) | Requires USDC from test 2 | +| 9 (USDC→MXN acct) | 0.05 USDC sending (50000) | Requires USDC from test 2 | +| 10 (USDC→UMA RT) | $0.10 locked receiving (10 cents) | Requires valid UMA receiver | +| 11 (USD→UMA acct) | $0.10 sending (10 cents) | Requires USD balance + valid UMA receiver | + +**Total per chain: ~1.3-1.5 USDC + gas fees** + +## Credential Schema + +`~/.grid-credentials` JSON file: + +```json +{ + "apiTokenId": "...", + "apiClientSecret": "...", + "baseUrl": "https://api.lightspark.com/grid/2025-10-13", + "solanaDevnetPrivateKey": "base58-encoded-64-byte-keypair", + "solanaMainnetPrivateKey": "base58-encoded-64-byte-keypair", + "baseTestnetPrivateKey": "hex-private-key-with-or-without-0x", + "baseMainnetPrivateKey": "hex-private-key-with-or-without-0x", + "polygonTestnetPrivateKey": "hex-private-key-with-or-without-0x", + "polygonMainnetPrivateKey": "hex-private-key-with-or-without-0x" +} +``` + +Only the keys for chains you want to test are required. The skill auto-skips chains without keys. diff --git a/.claude/skills/grid-test/references/test-catalog.md b/.claude/skills/grid-test/references/test-catalog.md new file mode 100644 index 00000000..3bacf6f4 --- /dev/null +++ b/.claude/skills/grid-test/references/test-catalog.md @@ -0,0 +1,713 @@ +# Test Catalog + +All tests use these chain variables (set per-chain in SKILL.md Step 4): +- `chain_helper` — shell function wrapping the chain's helper script (defined in SKILL.md Step 4) +- `$CRYPTO_NETWORK` — e.g., `SOLANA_DEVNET`, `BASE_TESTNET`, `POLYGON_TESTNET` +- `$WALLET_TYPE` — e.g., `SOLANA_WALLET`, `BASE_WALLET`, `POLYGON_WALLET` +- `$WALLET_ADDRESS` — the test wallet's on-chain address +- `$TRANSFER_OUT_AMT` — chain-specific minimum transfer-out amount +- `$IS_SANDBOX` — whether the platform is sandbox +- `$CHAIN_PREFIX` — unique prefix for this chain run (e.g., `solana-test`, `base-test`, `polygon-test`) + +API credentials are in environment variables: `$GRID_API_TOKEN_ID`, `$GRID_API_CLIENT_SECRET`, `$GRID_BASE_URL`. + +--- + +## Test 1: Customer + USDC Account Creation + +**Goal:** Create a customer and verify USDC internal account with chain-specific wallet funding instructions. + +**Steps:** + +1. Create a customer: + +```bash +PLATFORM_CUSTOMER_ID="$CHAIN_PREFIX-$(date +%s)" +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerType\": \"INDIVIDUAL\", + \"platformCustomerId\": \"$PLATFORM_CUSTOMER_ID\", + \"fullName\": \"Grid Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"US\" + }" \ + "$GRID_BASE_URL/customers" +``` + +Extract and save `CUSTOMER_ID` from the response `id` field. + +2. List internal accounts: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID&limit=100" +``` + +3. From the response, extract: + - `USDC_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USDC` + - `USD_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USD` + - `DEPOSIT_ADDRESS`: from the USDC account's `fundingPaymentInstructions` array, find the entry where `accountOrWalletInfo.accountType` is `$WALLET_TYPE` and extract `accountOrWalletInfo.address` + +4. **PASS criteria:** + - Customer created successfully + - USDC internal account exists + - `fundingPaymentInstructions` contains a `$WALLET_TYPE` entry with a non-empty `address` + +--- + +## Test 2: Fund Internal Account with Real Testnet USDC + +**Goal:** Send real USDC on the chain's testnet and verify Grid detects the deposit. + +**Steps:** + +1. Record initial USDC balance from internal account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +Save initial `balance.amount` as `INITIAL_USDC_BALANCE`. + +2. Send 0.50 USDC to the deposit address: + +```bash +chain_helper send-usdc --to $DEPOSIT_ADDRESS --amount 500000 +``` + +Verify the send was confirmed (status = "confirmed"). + +3. Poll for balance update every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +4. **PASS criteria:** USDC internal account `balance.amount` increases above `INITIAL_USDC_BALANCE`. + +--- + +## Test 3: Transfer Out (USDC internal -> external wallet) + +**Goal:** Withdraw USDC from internal account to an external wallet on this chain. + +**Steps:** + +1. Create an external account for our wallet: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"$CRYPTO_NETWORK\", + \"accountInfo\": { + \"accountType\": \"$WALLET_TYPE\", + \"address\": \"$WALLET_ADDRESS\" + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `USDC_EXTERNAL_ID`. + +2. Record initial on-chain USDC balance: + +```bash +chain_helper usdc-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_USDC`. + +3. Transfer out: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": {\"accountId\": \"$USDC_INTERNAL_ID\"}, + \"destination\": {\"accountId\": \"$USDC_EXTERNAL_ID\"}, + \"amount\": $TRANSFER_OUT_AMT + }" \ + "$GRID_BASE_URL/transfer-out" +``` + +4. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: + +```bash +chain_helper usdc-balance +``` + +5. **PASS criteria:** On-chain USDC balance (`raw`) increases above `INITIAL_ONCHAIN_USDC`. + +--- + +## Test 4: USDC -> USD Quote (Real-Time Funded -> internal USD account) + +**Goal:** Use real-time funding to convert USDC to USD, depositing into the customer's internal USD account. + +**Steps:** + +1. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"$CRYPTO_NETWORK\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +2. Extract from the response: + - `QUOTE_ID`: the `id` field + - `TRANSACTION_ID`: the `transactionId` field + - `PAYMENT_ADDRESS`: from `paymentInstructions`, find the `$WALLET_TYPE` entry and extract `accountOrWalletInfo.address` + - `TOTAL_SENDING_AMOUNT`: the `totalSendingAmount` field (micro-USDC amount to send) + +3. Record initial USD internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +Save `balance.amount` as `INITIAL_USD_BALANCE`. + +4. Send USDC to the payment instructions address: + +```bash +chain_helper send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll USD internal account balance every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +6. **PASS criteria:** USD internal account `balance.amount` increases above `INITIAL_USD_BALANCE`. + +--- + +## Test 5: USDC -> USD Quote (Real-Time Funded -> external USD bank account) + +**Goal:** Convert USDC to USD and send to an external bank account via ACH. + +**Steps:** + +1. Create an external USD bank account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USD\", + \"accountInfo\": { + \"accountType\": \"USD_ACCOUNT\", + \"paymentRails\": [\"ACH\"], + \"routingNumber\": \"021000021\", + \"accountNumber\": \"123456789012\", + \"accountCategory\": \"CHECKING\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"Grid Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"US\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `USD_EXTERNAL_ID`. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"$CRYPTO_NETWORK\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_EXTERNAL_ID\", + \"paymentRail\": \"ACH\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT` as in Test 4. + +4. Send USDC to the payment instructions address: + +```bash +chain_helper send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +## Test 6: USDC -> MXN Quote (Real-Time Funded -> external MXN CLABE account) + +**Goal:** Convert USDC to MXN and send to a Mexican bank account via SPEI. + +**Steps:** + +1. Create an external MXN account: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"MXN\", + \"accountInfo\": { + \"accountType\": \"MXN_ACCOUNT\", + \"paymentRails\": [\"SPEI\"], + \"clabeNumber\": \"032180000118359719\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"Grid Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"MX\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `MXN_EXTERNAL_ID`. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"$CRYPTO_NETWORK\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$MXN_EXTERNAL_ID\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 1100, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Note: `lockedCurrencyAmount: 1100` = 11.00 MXN (centavos), roughly ~$0.55 USD. Some environments enforce a minimum of 1100 centavos. If the quote returns `AMOUNT_OUT_OF_RANGE`, increase the amount to the specified minimum. Do not include `paymentRail` — the API infers it from the external account. + +3. Extract `QUOTE_ID`, `TRANSACTION_ID`, `PAYMENT_ADDRESS`, and `TOTAL_SENDING_AMOUNT`. + +4. Send USDC: + +```bash +chain_helper send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +## Test 7: USD -> USDC Quote (Account-Funded -> external wallet) + +**Goal:** Convert USD from internal account to USDC delivered to our external wallet on this chain. + +**Steps:** + +1. Fund the USD internal account: + + **If `IS_SANDBOX=true`:** Use the sandbox fund endpoint: + + ```bash + curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d '{"amount": 100}' \ + "$GRID_BASE_URL/sandbox/internal-accounts/$USD_INTERNAL_ID/fund" + ``` + + Verify the balance increased. + + **If `IS_SANDBOX=false`:** Check the current USD internal account balance. If balance is 0, skip this test with: "SKIP: Non-sandbox platform with no USD balance. Requires a prior USDC->USD conversion (Test 4) or manual funding." If balance > 0, proceed. + +2. Record initial on-chain USDC balance: + +```bash +chain_helper usdc-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_USDC_T7`. + +3. Ensure USDC external account exists (reuse `$USDC_EXTERNAL_ID` from Test 3). If Test 3 was skipped, create it now using the same pattern as Test 3 step 1. + +4. Create an account-funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USDC_EXTERNAL_ID\" + }, + \"lockedCurrencySide\": \"SENDING\", + \"lockedCurrencyAmount\": 50, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Save `QUOTE_ID` and `TRANSACTION_ID` from the response. + +5. Execute the quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST \ + "$GRID_BASE_URL/quotes/$QUOTE_ID/execute" +``` + +6. Poll on-chain USDC balance every 5 seconds, up to 120 seconds: + +```bash +chain_helper usdc-balance +``` + +7. **PASS criteria:** On-chain USDC balance (`raw`) increases above `INITIAL_ONCHAIN_USDC_T7`. + +--- + +## Test 8: USDC -> USD Quote (Account-Funded -> internal USD account) + +**Goal:** Convert USDC from internal account to USD in the customer's internal USD account, using existing USDC balance (no real-time funding). + +**Steps:** + +1. Check USDC internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +If `balance.amount` is 0, skip this test with: "SKIP: No USDC in internal account. Requires a prior deposit (Test 2)." + +2. Record initial USD internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +Save `balance.amount` as `INITIAL_USD_BALANCE_T8`. + +3. Create an account-funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"ACCOUNT\", + \"accountId\": \"$USDC_INTERNAL_ID\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"lockedCurrencySide\": \"SENDING\", + \"lockedCurrencyAmount\": 50000, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Save `QUOTE_ID` and `TRANSACTION_ID`. + +4. Execute the quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST \ + "$GRID_BASE_URL/quotes/$QUOTE_ID/execute" +``` + +5. Poll USD internal account balance every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +6. **PASS criteria:** USD internal account `balance.amount` increases above `INITIAL_USD_BALANCE_T8`. + +--- + +## Test 9: USDC -> MXN Quote (Account-Funded -> external MXN CLABE account) + +**Goal:** Convert USDC from internal account to MXN and send to a Mexican bank account, using existing USDC balance. + +**Steps:** + +1. Check USDC internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USDC" +``` + +If `balance.amount` is 0, skip this test with: "SKIP: No USDC in internal account. Requires a prior deposit (Test 2)." + +2. Ensure MXN external account exists (reuse `$MXN_EXTERNAL_ID` from Test 6). If Test 6 was skipped, create it now: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"MXN\", + \"accountInfo\": { + \"accountType\": \"MXN_ACCOUNT\", + \"paymentRails\": [\"SPEI\"], + \"clabeNumber\": \"032180000118359719\", + \"beneficiary\": { + \"beneficiaryType\": \"INDIVIDUAL\", + \"fullName\": \"Grid Test User\", + \"birthDate\": \"1990-01-15\", + \"nationality\": \"MX\" + } + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save `id` as `MXN_EXTERNAL_ID`. + +3. Create an account-funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"source\": { + \"sourceType\": \"ACCOUNT\", + \"accountId\": \"$USDC_INTERNAL_ID\" + }, + \"destination\": { + \"destinationType\": \"ACCOUNT\", + \"accountId\": \"$MXN_EXTERNAL_ID\" + }, + \"lockedCurrencySide\": \"SENDING\", + \"lockedCurrencyAmount\": 50000, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Save `QUOTE_ID` and `TRANSACTION_ID`. + +4. Execute the quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST \ + "$GRID_BASE_URL/quotes/$QUOTE_ID/execute" +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +## Test 10: USDC -> USD Quote (Real-Time Funded -> UMA address) + +**Goal:** Use real-time USDC funding to send USD to a UMA address. + +**Steps:** + +1. Look up a UMA receiver. Use `$UMA_RECEIVER` if set, otherwise default to `$test@sandbox.grid.uma.money`: + +```bash +UMA_RECEIVER="${UMA_RECEIVER:-\$test@sandbox.grid.uma.money}" +UMA_ENCODED=$(echo "$UMA_RECEIVER" | sed 's/\$/%24/g; s/@/%40/g') +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/receiver/uma/$UMA_ENCODED" +``` + +If the lookup fails or returns an error, skip this test with: "SKIP: UMA receiver lookup failed. Set `UMA_RECEIVER` in environment or ensure sandbox UMA is available." + +Save the `id` as `LOOKUP_ID`. Note the supported receiving currencies from the response. + +2. Create a real-time funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"lookupId\": \"$LOOKUP_ID\", + \"source\": { + \"sourceType\": \"REALTIME_FUNDING\", + \"customerId\": \"$CUSTOMER_ID\", + \"currency\": \"USDC\", + \"cryptoNetwork\": \"$CRYPTO_NETWORK\" + }, + \"destination\": { + \"destinationType\": \"UMA_ADDRESS\", + \"umaAddress\": \"$UMA_RECEIVER\", + \"currency\": \"USD\" + }, + \"lockedCurrencySide\": \"RECEIVING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +3. Extract from the response: + - `QUOTE_ID`: the `id` field + - `TRANSACTION_ID`: the `transactionId` field + - `PAYMENT_ADDRESS`: from `paymentInstructions`, find the `$WALLET_TYPE` entry and extract `accountOrWalletInfo.address` + - `TOTAL_SENDING_AMOUNT`: the `totalSendingAmount` field + +4. Send USDC to the payment instructions address: + +```bash +chain_helper send-usdc --to $PAYMENT_ADDRESS --amount $TOTAL_SENDING_AMOUNT +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. + +--- + +## Test 11: USD -> USD Quote (Account-Funded -> UMA address) + +**Goal:** Send USD from internal account to a UMA address. + +**Steps:** + +1. Check USD internal account balance: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/customers/internal-accounts?customerId=$CUSTOMER_ID¤cy=USD" +``` + +If `balance.amount` is 0: +- If `IS_SANDBOX=true`, fund it first: + ```bash + curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d '{"amount": 100}' \ + "$GRID_BASE_URL/sandbox/internal-accounts/$USD_INTERNAL_ID/fund" + ``` +- If `IS_SANDBOX=false`, skip this test with: "SKIP: Non-sandbox platform with no USD balance." + +2. Look up a UMA receiver (reuse `$LOOKUP_ID` and `$UMA_RECEIVER` from Test 10). If Test 10 was skipped, perform the lookup now using the same pattern as Test 10 step 1. If lookup fails, skip this test. + +3. Create an account-funded quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{ + \"lookupId\": \"$LOOKUP_ID\", + \"source\": { + \"sourceType\": \"ACCOUNT\", + \"accountId\": \"$USD_INTERNAL_ID\" + }, + \"destination\": { + \"destinationType\": \"UMA_ADDRESS\", + \"umaAddress\": \"$UMA_RECEIVER\", + \"currency\": \"USD\" + }, + \"lockedCurrencySide\": \"SENDING\", + \"lockedCurrencyAmount\": 10, + \"purposeOfPayment\": \"GOODS_OR_SERVICES\" + }" \ + "$GRID_BASE_URL/quotes" +``` + +Save `QUOTE_ID` and `TRANSACTION_ID`. + +4. Execute the quote: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST \ + "$GRID_BASE_URL/quotes/$QUOTE_ID/execute" +``` + +5. Poll transaction status every 5 seconds, up to 120 seconds: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + "$GRID_BASE_URL/transactions/$TRANSACTION_ID" +``` + +6. **PASS criteria:** Transaction status reaches `PROCESSING` or `COMPLETED`. diff --git a/.claude/skills/grid-test/scripts/base_helper.py b/.claude/skills/grid-test/scripts/base_helper.py new file mode 100644 index 00000000..2c43d639 --- /dev/null +++ b/.claude/skills/grid-test/scripts/base_helper.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Base chain CLI for Grid USDC testing. + +Supports both Base Sepolia testnet (default) and Base mainnet (--mainnet flag). + +Subcommands: + wallet-address Print public address of loaded key + eth-balance [--address] Print ETH balance + usdc-balance [--address] Print USDC balance (raw + human-readable) + send-usdc --to --amount Send USDC (amount in micro-USDC, 6 decimals) +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + from web3 import Web3 + from eth_account import Account +except ImportError as e: + print(json.dumps({"error": "Missing dependencies. Install with: pip3 install web3", "detail": str(e)})) + sys.exit(1) + +NETWORKS = { + "testnet": { + "rpc": "https://sepolia.base.org", + "chain_id": 84532, + "usdc_contract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "cred_key": "baseTestnetPrivateKey", + "name": "Base Sepolia", + "priority_fee_gwei": 0.001, + }, + "mainnet": { + "rpc": "https://mainnet.base.org", + "chain_id": 8453, + "usdc_contract": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "cred_key": "baseMainnetPrivateKey", + "name": "Base Mainnet", + "priority_fee_gwei": 0.01, + }, +} + +USDC_DECIMALS = 6 + +ERC20_ABI = [ + { + "constant": True, + "inputs": [{"name": "_owner", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "balance", "type": "uint256"}], + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_to", "type": "address"}, + {"name": "_value", "type": "uint256"}, + ], + "name": "transfer", + "outputs": [{"name": "", "type": "bool"}], + "type": "function", + }, +] + +NET = None # set in main() + + +def load_account(creds_path=None): + creds_path = creds_path or os.path.expanduser("~/.grid-credentials") + with open(creds_path) as f: + creds = json.load(f) + private_key = creds.get(NET["cred_key"]) + if not private_key: + print(json.dumps({"error": f"{NET['cred_key']} not found in ~/.grid-credentials"})) + sys.exit(1) + if not private_key.startswith("0x"): + private_key = "0x" + private_key + return Account.from_key(private_key) + + +def get_web3(): + w3 = Web3(Web3.HTTPProvider(NET["rpc"])) + if not w3.is_connected(): + print(json.dumps({"error": f"Failed to connect to {NET['name']} RPC", "rpc": NET["rpc"]})) + sys.exit(1) + return w3 + + +def get_usdc_contract(w3): + return w3.eth.contract(address=Web3.to_checksum_address(NET["usdc_contract"]), abi=ERC20_ABI) + + +def cmd_wallet_address(args): + acct = load_account() + print(json.dumps({"address": acct.address})) + + +def cmd_eth_balance(args): + w3 = get_web3() + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + balance_wei = w3.eth.get_balance(address) + print(json.dumps({ + "address": address, + "wei": balance_wei, + "eth": float(Web3.from_wei(balance_wei, "ether")), + })) + + +def cmd_usdc_balance(args): + w3 = get_web3() + usdc = get_usdc_contract(w3) + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + raw = usdc.functions.balanceOf(address).call() + print(json.dumps({ + "address": address, + "contract": NET["usdc_contract"], + "raw": raw, + "amount": raw / (10 ** USDC_DECIMALS), + "ui_amount": f"{raw / (10 ** USDC_DECIMALS):.6f}", + })) + + +def cmd_send_usdc(args): + acct = load_account() + w3 = get_web3() + usdc = get_usdc_contract(w3) + recipient = Web3.to_checksum_address(args.to) + amount = int(args.amount) + + nonce = w3.eth.get_transaction_count(acct.address) + + tx = usdc.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": w3.eth.gas_price * 2, + "maxPriorityFeePerGas": w3.to_wei(NET["priority_fee_gwei"], "gwei"), + }) + + signed = acct.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash_hex = tx_hash.hex() + + print(json.dumps({"status": "sent", "tx_hash": tx_hash_hex, "message": "Waiting for confirmation..."})) + + for _ in range(60): + time.sleep(2) + try: + receipt = w3.eth.get_transaction_receipt(tx_hash) + if receipt is not None: + if receipt["status"] == 1: + print(json.dumps({"status": "confirmed", "tx_hash": tx_hash_hex, "block": receipt["blockNumber"]})) + return + else: + print(json.dumps({"status": "failed", "tx_hash": tx_hash_hex, "receipt_status": receipt["status"]})) + sys.exit(1) + except Exception: + pass + + print(json.dumps({"status": "timeout", "tx_hash": tx_hash_hex, "message": "Transaction sent but confirmation timed out."})) + sys.exit(1) + + +def main(): + global NET + + parser = argparse.ArgumentParser(description="Base chain helper for Grid USDC testing") + parser.add_argument("--mainnet", action="store_true", help="Use Base mainnet instead of Sepolia testnet") + sub = parser.add_subparsers(dest="command") + sub.required = True + + sub.add_parser("wallet-address", help="Print public address of loaded key") + + eth_bal = sub.add_parser("eth-balance", help="Print ETH balance") + eth_bal.add_argument("--address", help="Address to check (default: own wallet)") + + usdc_bal = sub.add_parser("usdc-balance", help="Print USDC balance") + usdc_bal.add_argument("--address", help="Address to check (default: own wallet)") + + send = sub.add_parser("send-usdc", help="Send USDC") + send.add_argument("--to", required=True, help="Recipient address (0x...)") + send.add_argument("--amount", required=True, help="Amount in micro-USDC (6 decimals)") + + args = parser.parse_args() + NET = NETWORKS["mainnet"] if args.mainnet else NETWORKS["testnet"] + + dispatch = { + "wallet-address": cmd_wallet_address, + "eth-balance": cmd_eth_balance, + "usdc-balance": cmd_usdc_balance, + "send-usdc": cmd_send_usdc, + } + dispatch[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/grid-test/scripts/polygon_helper.py b/.claude/skills/grid-test/scripts/polygon_helper.py new file mode 100644 index 00000000..86bfcb0f --- /dev/null +++ b/.claude/skills/grid-test/scripts/polygon_helper.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Polygon chain CLI for Grid USDC testing. + +Supports both Polygon Amoy testnet (default) and Polygon mainnet (--mainnet flag). + +Subcommands: + wallet-address Print public address of loaded key + pol-balance [--address] Print POL balance + usdc-balance [--address] Print USDC balance (raw + human-readable) + send-usdc --to --amount Send USDC (amount in micro-USDC, 6 decimals) +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + from web3 import Web3 + from eth_account import Account +except ImportError as e: + print(json.dumps({"error": "Missing dependencies. Install with: pip3 install web3", "detail": str(e)})) + sys.exit(1) + +NETWORKS = { + "testnet": { + "rpc": "https://rpc-amoy.polygon.technology", + "chain_id": 80002, + "usdc_contract": "0x41E94Eb71898E8B51d136F15b58AAcb90f0b7e70", + "cred_key": "polygonTestnetPrivateKey", + "name": "Polygon Amoy", + "priority_fee_gwei": 30, + }, + "mainnet": { + "rpc": "https://polygon-rpc.com", + "chain_id": 137, + "usdc_contract": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "cred_key": "polygonMainnetPrivateKey", + "name": "Polygon Mainnet", + "priority_fee_gwei": 30, + }, +} + +USDC_DECIMALS = 6 + +ERC20_ABI = [ + { + "constant": True, + "inputs": [{"name": "_owner", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "balance", "type": "uint256"}], + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_to", "type": "address"}, + {"name": "_value", "type": "uint256"}, + ], + "name": "transfer", + "outputs": [{"name": "", "type": "bool"}], + "type": "function", + }, +] + +NET = None # set in main() + + +def load_account(creds_path=None): + creds_path = creds_path or os.path.expanduser("~/.grid-credentials") + with open(creds_path) as f: + creds = json.load(f) + private_key = creds.get(NET["cred_key"]) or creds.get("polygonPrivateKey") + if not private_key: + print(json.dumps({"error": f"{NET['cred_key']} not found in ~/.grid-credentials"})) + sys.exit(1) + if not private_key.startswith("0x"): + private_key = "0x" + private_key + return Account.from_key(private_key) + + +def get_web3(): + w3 = Web3(Web3.HTTPProvider(NET["rpc"])) + if not w3.is_connected(): + print(json.dumps({"error": f"Failed to connect to {NET['name']} RPC", "rpc": NET["rpc"]})) + sys.exit(1) + return w3 + + +def get_usdc_contract(w3): + return w3.eth.contract(address=Web3.to_checksum_address(NET["usdc_contract"]), abi=ERC20_ABI) + + +def cmd_wallet_address(args): + acct = load_account() + print(json.dumps({"address": acct.address})) + + +def cmd_pol_balance(args): + w3 = get_web3() + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + balance_wei = w3.eth.get_balance(address) + print(json.dumps({ + "address": address, + "wei": balance_wei, + "pol": float(Web3.from_wei(balance_wei, "ether")), + })) + + +def cmd_usdc_balance(args): + w3 = get_web3() + usdc = get_usdc_contract(w3) + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + raw = usdc.functions.balanceOf(address).call() + print(json.dumps({ + "address": address, + "contract": NET["usdc_contract"], + "raw": raw, + "amount": raw / (10 ** USDC_DECIMALS), + "ui_amount": f"{raw / (10 ** USDC_DECIMALS):.6f}", + })) + + +def cmd_send_usdc(args): + acct = load_account() + w3 = get_web3() + usdc = get_usdc_contract(w3) + recipient = Web3.to_checksum_address(args.to) + amount = int(args.amount) + + nonce = w3.eth.get_transaction_count(acct.address) + + tx = usdc.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": w3.eth.gas_price * 2, + "maxPriorityFeePerGas": w3.to_wei(NET["priority_fee_gwei"], "gwei"), + }) + + signed = acct.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash_hex = tx_hash.hex() + + print(json.dumps({"status": "sent", "tx_hash": tx_hash_hex, "message": "Waiting for confirmation..."})) + + for _ in range(60): + time.sleep(2) + try: + receipt = w3.eth.get_transaction_receipt(tx_hash) + if receipt is not None: + if receipt["status"] == 1: + print(json.dumps({"status": "confirmed", "tx_hash": tx_hash_hex, "block": receipt["blockNumber"]})) + return + else: + print(json.dumps({"status": "failed", "tx_hash": tx_hash_hex, "receipt_status": receipt["status"]})) + sys.exit(1) + except Exception: + pass + + print(json.dumps({"status": "timeout", "tx_hash": tx_hash_hex, "message": "Transaction sent but confirmation timed out."})) + sys.exit(1) + + +def main(): + global NET + + parser = argparse.ArgumentParser(description="Polygon chain helper for Grid USDC testing") + parser.add_argument("--mainnet", action="store_true", help="Use Polygon mainnet instead of Amoy testnet") + sub = parser.add_subparsers(dest="command") + sub.required = True + + sub.add_parser("wallet-address", help="Print public address of loaded key") + + pol_bal = sub.add_parser("pol-balance", help="Print POL balance") + pol_bal.add_argument("--address", help="Address to check (default: own wallet)") + + usdc_bal = sub.add_parser("usdc-balance", help="Print USDC balance") + usdc_bal.add_argument("--address", help="Address to check (default: own wallet)") + + send = sub.add_parser("send-usdc", help="Send USDC") + send.add_argument("--to", required=True, help="Recipient address (0x...)") + send.add_argument("--amount", required=True, help="Amount in micro-USDC (6 decimals)") + + args = parser.parse_args() + NET = NETWORKS["mainnet"] if args.mainnet else NETWORKS["testnet"] + + dispatch = { + "wallet-address": cmd_wallet_address, + "pol-balance": cmd_pol_balance, + "usdc-balance": cmd_usdc_balance, + "send-usdc": cmd_send_usdc, + } + dispatch[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/grid-test/scripts/solana_helper.py b/.claude/skills/grid-test/scripts/solana_helper.py new file mode 100644 index 00000000..3b2498c4 --- /dev/null +++ b/.claude/skills/grid-test/scripts/solana_helper.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Solana CLI for Grid USDC testing. + +Supports both devnet (default) and mainnet (--mainnet flag). + +Subcommands: + wallet-address Print public key of loaded keypair + sol-balance [--address] Print SOL balance + usdc-balance [--address] Print USDC balance (raw + human-readable) + send-usdc --to --amount Send USDC (amount in micro-USDC) + airdrop-sol [--amount] Request devnet SOL airdrop (devnet only) +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + import base58 + from solders.keypair import Keypair + from solders.pubkey import Pubkey + from solders.system_program import ID as SYS_PROGRAM_ID + from solders.transaction import Transaction + from solders.message import Message + from solders.instruction import Instruction, AccountMeta + from solders.hash import Hash + from solana.rpc.api import Client + from solana.rpc.commitment import Confirmed, Finalized + from solana.rpc.types import TxOpts + import struct +except ImportError as e: + print(json.dumps({"error": "Missing dependencies. Install with: pip3 install solders solana base58", "detail": str(e)})) + sys.exit(1) + +NETWORKS = { + "devnet": { + "rpc": "https://api.devnet.solana.com", + "usdc_mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "cred_key": "solanaDevnetPrivateKey", + }, + "mainnet": { + "rpc": "https://api.mainnet-beta.solana.com", + "usdc_mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "cred_key": "solanaMainnetPrivateKey", + }, +} + +TOKEN_PROGRAM_ID = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") +ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") +USDC_DECIMALS = 6 + +NET = None # set in main() + + +def load_keypair(creds_path=None): + creds_path = creds_path or os.path.expanduser("~/.grid-credentials") + with open(creds_path) as f: + creds = json.load(f) + secret_key = creds.get(NET["cred_key"]) + if not secret_key: + print(json.dumps({"error": f"{NET['cred_key']} not found in ~/.grid-credentials"})) + sys.exit(1) + raw = base58.b58decode(secret_key) + if len(raw) == 32: + return Keypair.from_seed(raw) + return Keypair.from_bytes(raw) + + +def get_client(): + return Client(NET["rpc"]) + + +def get_ata(owner, mint): + seeds = [bytes(owner), bytes(TOKEN_PROGRAM_ID), bytes(mint)] + ata, _bump = Pubkey.find_program_address(seeds, ASSOCIATED_TOKEN_PROGRAM_ID) + return ata + + +def get_token_balance(client, address, mint_str): + mint = Pubkey.from_string(mint_str) + ata = get_ata(address, mint) + try: + resp = client.get_token_account_balance(ata) + except Exception: + return 0, "0" + if resp.value is None: + return 0, "0" + return int(resp.value.amount), resp.value.ui_amount_string + + +def cmd_wallet_address(args): + kp = load_keypair() + print(json.dumps({"address": str(kp.pubkey())})) + + +def cmd_sol_balance(args): + client = get_client() + if args.address: + pubkey = Pubkey.from_string(args.address) + else: + kp = load_keypair() + pubkey = kp.pubkey() + resp = client.get_balance(pubkey, commitment=Confirmed) + lamports = resp.value + print(json.dumps({ + "address": str(pubkey), + "lamports": lamports, + "sol": lamports / 1e9 + })) + + +def cmd_usdc_balance(args): + client = get_client() + mint_str = args.mint or NET["usdc_mint"] + if args.address: + pubkey = Pubkey.from_string(args.address) + else: + kp = load_keypair() + pubkey = kp.pubkey() + raw_amount, ui_amount = get_token_balance(client, pubkey, mint_str) + print(json.dumps({ + "address": str(pubkey), + "mint": mint_str, + "raw": raw_amount, + "amount": raw_amount / (10 ** USDC_DECIMALS), + "ui_amount": ui_amount + })) + + +def cmd_send_usdc(args): + kp = load_keypair() + client = get_client() + mint_str = args.mint or NET["usdc_mint"] + mint = Pubkey.from_string(mint_str) + recipient = Pubkey.from_string(args.to) + amount = int(args.amount) + + sender_ata = get_ata(kp.pubkey(), mint) + recipient_ata = get_ata(recipient, mint) + + instructions = [] + + recipient_ata_info = client.get_account_info(recipient_ata) + if recipient_ata_info.value is None: + create_ata_ix = Instruction( + program_id=ASSOCIATED_TOKEN_PROGRAM_ID, + accounts=[ + AccountMeta(pubkey=kp.pubkey(), is_signer=True, is_writable=True), + AccountMeta(pubkey=recipient_ata, is_signer=False, is_writable=True), + AccountMeta(pubkey=recipient, is_signer=False, is_writable=False), + AccountMeta(pubkey=mint, is_signer=False, is_writable=False), + AccountMeta(pubkey=SYS_PROGRAM_ID, is_signer=False, is_writable=False), + AccountMeta(pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False), + ], + data=bytes(), + ) + instructions.append(create_ata_ix) + + transfer_data = bytearray([12]) + transfer_data.extend(struct.pack(" Date: Mon, 13 Apr 2026 16:18:49 -0700 Subject: [PATCH 6/7] Fix amoy usdc contract --- .claude/skills/grid-test/scripts/polygon_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/grid-test/scripts/polygon_helper.py b/.claude/skills/grid-test/scripts/polygon_helper.py index 86bfcb0f..33ce20a6 100644 --- a/.claude/skills/grid-test/scripts/polygon_helper.py +++ b/.claude/skills/grid-test/scripts/polygon_helper.py @@ -28,7 +28,7 @@ "testnet": { "rpc": "https://rpc-amoy.polygon.technology", "chain_id": 80002, - "usdc_contract": "0x41E94Eb71898E8B51d136F15b58AAcb90f0b7e70", + "usdc_contract": "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", "cred_key": "polygonTestnetPrivateKey", "name": "Polygon Amoy", "priority_fee_gwei": 30, From 83d9e52b38ed3cb753531695e5f76916f5b68046 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Sat, 18 Apr 2026 13:46:15 -0700 Subject: [PATCH 7/7] Add eth L1 to test skill --- .claude/skills/grid-test/SKILL.md | 54 +++-- .../grid-test/scripts/ethereum_helper.py | 209 ++++++++++++++++++ 2 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 .claude/skills/grid-test/scripts/ethereum_helper.py diff --git a/.claude/skills/grid-test/SKILL.md b/.claude/skills/grid-test/SKILL.md index b30853ef..2a6dc881 100644 --- a/.claude/skills/grid-test/SKILL.md +++ b/.claude/skills/grid-test/SKILL.md @@ -2,7 +2,8 @@ name: grid-test description: > This skill should be used when the user asks to "test Grid", "run USDC tests", "test deposits", - "test withdrawals", "test Solana flows", "test Base flows", "test Polygon flows", "run e2e tests", + "test withdrawals", "test Solana flows", "test Base flows", "test Polygon flows", + "test Ethereum flows", "test ETH L1", "run e2e tests", "test sandbox", "test USDC to USD", "test USDC to MXN", "run all Grid tests", "test transfer out", "test realtime funding", "test quote flows", "test deposits and withdrawals", "run sandbox tests", "test USDC sandbox", "test Grid API", "run e2e USDC test", @@ -19,15 +20,15 @@ allowed-tools: # Grid API Test Suite -End-to-end tests for USDC flows on Solana, Base, and Polygon: deposits, withdrawals, and cross-currency quotes using real testnet (or mainnet) funds. +End-to-end tests for USDC flows on Solana, Base, Polygon, and Ethereum L1: deposits, withdrawals, and cross-currency quotes using real testnet (or mainnet) funds. ## Step 1: Parse the User's Prompt Determine what to run from the user's request: **Chains** (default: all available — see step 3 for which have keys): -- `solana`, `base`, `polygon`, or `all` -- Multiple chains: "test solana and base", "run base and polygon tests" +- `solana`, `base`, `polygon`, `ethereum`, or `all` +- Multiple chains: "test solana and base", "run base and polygon tests", "test ethereum" **Tests** (default: all): - By number: "run test 4 on solana" @@ -97,26 +98,26 @@ For each chain the user wants to test, set the chain-specific variables and veri **Testnet (sandbox/dev):** -| Variable | Solana | Base | Polygon | -|---|---|---|---| -| `CRYPTO_NETWORK` | `SOLANA_DEVNET` | `BASE_TESTNET` | `POLYGON_TESTNET` | -| `WALLET_TYPE` | `SOLANA_WALLET` | `BASE_WALLET` | `POLYGON_WALLET` | -| `CRED_KEY` | `solanaDevnetPrivateKey` | `baseTestnetPrivateKey` | `polygonTestnetPrivateKey` | -| `HELPER_SCRIPT` | `scripts/solana_helper.py` | `scripts/base_helper.py` | `scripts/polygon_helper.py` | -| `GAS_CMD` | `sol-balance` | `eth-balance` | `pol-balance` | -| `GAS_TOKEN` | SOL | ETH | POL | -| `GAS_MIN` | 0.1 | 0.001 | 0.1 | -| `TRANSFER_OUT_AMT` | 100000 | 200000 | 200000 | -| `PIP_DEPS` | `solders solana base58` | `web3` | `web3` | +| Variable | Solana | Base | Polygon | Ethereum | +|---|---|---|---|---| +| `CRYPTO_NETWORK` | `SOLANA_DEVNET` | `BASE_TESTNET` | `POLYGON_TESTNET` | `ETHEREUM_TESTNET` | +| `WALLET_TYPE` | `SOLANA_WALLET` | `BASE_WALLET` | `POLYGON_WALLET` | `ETHEREUM_WALLET` | +| `CRED_KEY` | `solanaDevnetPrivateKey` | `baseTestnetPrivateKey` | `polygonTestnetPrivateKey` | `ethereumTestnetPrivateKey` | +| `HELPER_SCRIPT` | `scripts/solana_helper.py` | `scripts/base_helper.py` | `scripts/polygon_helper.py` | `scripts/ethereum_helper.py` | +| `GAS_CMD` | `sol-balance` | `eth-balance` | `pol-balance` | `eth-balance` | +| `GAS_TOKEN` | SOL | ETH | POL | ETH | +| `GAS_MIN` | 0.1 | 0.001 | 0.1 | 0.01 | +| `TRANSFER_OUT_AMT` | 100000 | 200000 | 200000 | 200000 | +| `PIP_DEPS` | `solders solana base58` | `web3` | `web3` | `web3` | **Mainnet (non-sandbox production):** -| Variable | Solana | Base | Polygon | -|---|---|---|---| -| `CRYPTO_NETWORK` | `SOLANA_MAINNET` | `BASE_MAINNET` | `POLYGON_MAINNET` | -| `WALLET_TYPE` | `SOLANA_WALLET` | `BASE_WALLET` | `POLYGON_WALLET` | -| `CRED_KEY` | `solanaMainnetPrivateKey` | `baseMainnetPrivateKey` | `polygonMainnetPrivateKey` | -| Other vars | Same as testnet | Same as testnet | Same as testnet | +| Variable | Solana | Base | Polygon | Ethereum | +|---|---|---|---|---| +| `CRYPTO_NETWORK` | `SOLANA_MAINNET` | `BASE_MAINNET` | `POLYGON_MAINNET` | `ETHEREUM_MAINNET` | +| `WALLET_TYPE` | `SOLANA_WALLET` | `BASE_WALLET` | `POLYGON_WALLET` | `ETHEREUM_WALLET` | +| `CRED_KEY` | `solanaMainnetPrivateKey` | `baseMainnetPrivateKey` | `polygonMainnetPrivateKey` | `ethereumMainnetPrivateKey` | +| Other vars | Same as testnet | Same as testnet | Same as testnet | Same as testnet | ### Per-chain prerequisites @@ -148,9 +149,10 @@ For each selected chain, run these checks. Skip a chain (with a warning) if its $CHAIN_HELPER $GAS_CMD ``` If below `GAS_MIN`, warn the user with instructions for obtaining testnet gas: - - Solana: `$CHAIN_HELPER airdrop-sol --amount 1000000000` + - Solana: `chain_helper airdrop-sol --amount 1000000000` - Base: https://www.alchemy.com/faucets/base-sepolia - Polygon: https://faucet.polygon.technology/ + - Ethereum: https://www.alchemy.com/faucets/ethereum-sepolia 5. **Check USDC balance:** ```bash @@ -160,6 +162,7 @@ For each selected chain, run these checks. Skip a chain (with a warning) if its - Solana: Solana devnet USDC faucet - Base: https://faucet.circle.com/ (select Base Sepolia) - Polygon: https://faucet.circle.com/ (select Polygon Amoy) + - Ethereum: https://faucet.circle.com/ (select Ethereum Sepolia) 6. **Get wallet address:** ```bash @@ -183,6 +186,7 @@ If running a subset, create the customer (Test 1) silently as setup, then run on - Solana: `CHAIN_PREFIX="solana-test"` - Base: `CHAIN_PREFIX="base-test"` - Polygon: `CHAIN_PREFIX="polygon-test"` +- Ethereum: `CHAIN_PREFIX="ethereum-test"` ## Step 6: Results Summary @@ -242,7 +246,7 @@ All tests use small amounts to conserve testnet funds: | Test | Amount | Notes | |------|--------|-------| | 2 (deposit) | 0.50 USDC (500000) | | -| 3 (transfer-out) | Solana: 0.10 USDC (100000), Base/Polygon: 0.20 USDC (200000) | Base/Polygon must exceed ~100100 custody fee | +| 3 (transfer-out) | Solana: 0.10 USDC (100000), Base/Polygon/Ethereum: 0.20 USDC (200000) | EVM chains must exceed ~100100 custody fee | | 4-5 (USDC→USD RT) | $0.10 locked receiving (10 cents) | | | 6 (USDC→MXN RT) | 11.00 MXN locked receiving (1100 centavos, ~$0.55) | Some envs enforce 1100 minimum | | 7 (USD→USDC) | $0.50 sending (50 cents) | Requires sandbox or prior USD balance | @@ -267,7 +271,9 @@ All tests use small amounts to conserve testnet funds: "baseTestnetPrivateKey": "hex-private-key-with-or-without-0x", "baseMainnetPrivateKey": "hex-private-key-with-or-without-0x", "polygonTestnetPrivateKey": "hex-private-key-with-or-without-0x", - "polygonMainnetPrivateKey": "hex-private-key-with-or-without-0x" + "polygonMainnetPrivateKey": "hex-private-key-with-or-without-0x", + "ethereumTestnetPrivateKey": "hex-private-key-with-or-without-0x", + "ethereumMainnetPrivateKey": "hex-private-key-with-or-without-0x" } ``` diff --git a/.claude/skills/grid-test/scripts/ethereum_helper.py b/.claude/skills/grid-test/scripts/ethereum_helper.py new file mode 100644 index 00000000..f57fdd65 --- /dev/null +++ b/.claude/skills/grid-test/scripts/ethereum_helper.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Ethereum L1 CLI for Grid USDC testing. + +Supports both Ethereum Sepolia testnet (default) and Ethereum mainnet (--mainnet flag). + +Subcommands: + wallet-address Print public address of loaded key + eth-balance [--address] Print ETH balance + usdc-balance [--address] Print USDC balance (raw + human-readable) + send-usdc --to --amount Send USDC (amount in micro-USDC, 6 decimals) +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +try: + from web3 import Web3 + from eth_account import Account +except ImportError as e: + print(json.dumps({"error": "Missing dependencies. Install with: pip3 install web3", "detail": str(e)})) + sys.exit(1) + +NETWORKS = { + "testnet": { + "rpc": "https://ethereum-sepolia-rpc.publicnode.com", + "chain_id": 11155111, + "usdc_contract": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "cred_key": "ethereumTestnetPrivateKey", + "name": "Ethereum Sepolia", + "priority_fee_gwei": 1.5, + }, + "mainnet": { + "rpc": "https://ethereum-rpc.publicnode.com", + "chain_id": 1, + "usdc_contract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "cred_key": "ethereumMainnetPrivateKey", + "name": "Ethereum Mainnet", + "priority_fee_gwei": 1.5, + }, +} + +USDC_DECIMALS = 6 + +ERC20_ABI = [ + { + "constant": True, + "inputs": [{"name": "_owner", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "balance", "type": "uint256"}], + "type": "function", + }, + { + "constant": False, + "inputs": [ + {"name": "_to", "type": "address"}, + {"name": "_value", "type": "uint256"}, + ], + "name": "transfer", + "outputs": [{"name": "", "type": "bool"}], + "type": "function", + }, +] + +NET = None # set in main() + + +def load_account(creds_path=None): + creds_path = creds_path or os.path.expanduser("~/.grid-credentials") + with open(creds_path) as f: + creds = json.load(f) + private_key = creds.get(NET["cred_key"]) + if not private_key: + print(json.dumps({"error": f"{NET['cred_key']} not found in ~/.grid-credentials"})) + sys.exit(1) + if not private_key.startswith("0x"): + private_key = "0x" + private_key + return Account.from_key(private_key) + + +def get_web3(): + w3 = Web3(Web3.HTTPProvider(NET["rpc"])) + if not w3.is_connected(): + print(json.dumps({"error": f"Failed to connect to {NET['name']} RPC", "rpc": NET["rpc"]})) + sys.exit(1) + return w3 + + +def get_usdc_contract(w3): + return w3.eth.contract(address=Web3.to_checksum_address(NET["usdc_contract"]), abi=ERC20_ABI) + + +def cmd_wallet_address(args): + acct = load_account() + print(json.dumps({"address": acct.address})) + + +def cmd_eth_balance(args): + w3 = get_web3() + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + balance_wei = w3.eth.get_balance(address) + print(json.dumps({ + "address": address, + "wei": balance_wei, + "eth": float(Web3.from_wei(balance_wei, "ether")), + })) + + +def cmd_usdc_balance(args): + w3 = get_web3() + usdc = get_usdc_contract(w3) + if args.address: + address = Web3.to_checksum_address(args.address) + else: + acct = load_account() + address = acct.address + raw = usdc.functions.balanceOf(address).call() + print(json.dumps({ + "address": address, + "contract": NET["usdc_contract"], + "raw": raw, + "amount": raw / (10 ** USDC_DECIMALS), + "ui_amount": f"{raw / (10 ** USDC_DECIMALS):.6f}", + })) + + +def cmd_send_usdc(args): + acct = load_account() + w3 = get_web3() + usdc = get_usdc_contract(w3) + recipient = Web3.to_checksum_address(args.to) + amount = int(args.amount) + + nonce = w3.eth.get_transaction_count(acct.address) + + tx = usdc.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": w3.eth.gas_price * 2, + "maxPriorityFeePerGas": w3.to_wei(NET["priority_fee_gwei"], "gwei"), + }) + + signed = acct.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + tx_hash_hex = tx_hash.hex() + + print(json.dumps({"status": "sent", "tx_hash": tx_hash_hex, "message": "Waiting for confirmation..."})) + + for _ in range(90): + time.sleep(2) + try: + receipt = w3.eth.get_transaction_receipt(tx_hash) + if receipt is not None: + if receipt["status"] == 1: + print(json.dumps({"status": "confirmed", "tx_hash": tx_hash_hex, "block": receipt["blockNumber"]})) + return + else: + print(json.dumps({"status": "failed", "tx_hash": tx_hash_hex, "receipt_status": receipt["status"]})) + sys.exit(1) + except Exception: + pass + + print(json.dumps({"status": "timeout", "tx_hash": tx_hash_hex, "message": "Transaction sent but confirmation timed out."})) + sys.exit(1) + + +def main(): + global NET + + parser = argparse.ArgumentParser(description="Ethereum L1 helper for Grid USDC testing") + parser.add_argument("--mainnet", action="store_true", help="Use Ethereum mainnet instead of Sepolia testnet") + sub = parser.add_subparsers(dest="command") + sub.required = True + + sub.add_parser("wallet-address", help="Print public address of loaded key") + + eth_bal = sub.add_parser("eth-balance", help="Print ETH balance") + eth_bal.add_argument("--address", help="Address to check (default: own wallet)") + + usdc_bal = sub.add_parser("usdc-balance", help="Print USDC balance") + usdc_bal.add_argument("--address", help="Address to check (default: own wallet)") + + send = sub.add_parser("send-usdc", help="Send USDC") + send.add_argument("--to", required=True, help="Recipient address (0x...)") + send.add_argument("--amount", required=True, help="Amount in micro-USDC (6 decimals)") + + args = parser.parse_args() + NET = NETWORKS["mainnet"] if args.mainnet else NETWORKS["testnet"] + + dispatch = { + "wallet-address": cmd_wallet_address, + "eth-balance": cmd_eth_balance, + "usdc-balance": cmd_usdc_balance, + "send-usdc": cmd_send_usdc, + } + dispatch[args.command](args) + + +if __name__ == "__main__": + main()