diff --git a/.claude/skills/grid-base-usdc-test/SKILL.md b/.claude/skills/grid-base-usdc-test/SKILL.md new file mode 100644 index 000000000..862b0b71a --- /dev/null +++ b/.claude/skills/grid-base-usdc-test/SKILL.md @@ -0,0 +1,558 @@ +--- +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) +``` + +### 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 +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.20 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\": 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 above `INITIAL_ONCHAIN_USDC` (net amount will be ~99900 after fees). + +--- + +### 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\" + }, + \"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. 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 +$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: + +**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 (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 +$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.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) — requires sandbox or prior USD balance +- **Total USDC needed: ~1.2 USDC + gas (~0.001 ETH on Base Sepolia)** diff --git a/.claude/skills/grid-base-usdc-test/base_helper.py b/.claude/skills/grid-base-usdc-test/base_helper.py new file mode 100644 index 000000000..f856a57da --- /dev/null +++ b/.claude/skills/grid-base-usdc-test/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() diff --git a/.claude/skills/grid-solana-usdc-sandbox/SKILL.md b/.claude/skills/grid-solana-usdc-sandbox/SKILL.md new file mode 100644 index 000000000..d92583404 --- /dev/null +++ b/.claude/skills/grid-solana-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-solana-usdc-sandbox/solana_helper.py b/.claude/skills/grid-solana-usdc-sandbox/solana_helper.py new file mode 100644 index 000000000..6906076dc --- /dev/null +++ b/.claude/skills/grid-solana-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(" + This skill should be used when the user asks to "test Grid", "run USDC tests", "run USDT tests", + "test deposits", "test withdrawals", "test Solana flows", "test Base flows", "test Polygon flows", + "test Ethereum flows", "test ETH L1", "test Tron flows", "test USDT on Tron", "test USDT on Ethereum", + "test USDT on eth", "test USDT on eth L1", "test USDC on Ethereum", "test Plasma flows", + "test USDT on Plasma", "test plasma", "run e2e tests", + "test sandbox", "test USDC to USD", "test USDT to USD", "test USDC to MXN", "test USDT 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 USDT sandbox", + "test Grid API", "run e2e USDC test", "run e2e USDT test", "test USDC on [chain]", + "test USDT on [chain]", or wants to verify Grid's stablecoin deposit/withdrawal/quote pipeline + (USDC on Solana/Base/Polygon/Ethereum, USDT on Ethereum/Plasma/Tron). + Even if the user mentions just one chain, one asset, 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 stablecoin flows: USDC on Solana, Base, Polygon, and Ethereum L1, and USDT on Ethereum L1, Plasma, and Tron. Covers deposits, withdrawals, and cross-currency quotes using real testnet (or mainnet) funds. + +A **target** is a chain paired with a stablecoin — `ethereum-usdc` and `ethereum-usdt` are separate targets that share a chain, helper script, private key, and wallet address. The tests themselves are asset-agnostic, parameterized over the target's `STABLE_ASSET` / `STABLE_CURRENCY`. + +## Step 1: Parse the User's Prompt + +Determine what to run from the user's request: + +**Targets** (default: all available — see step 4 for which have keys): +- `solana-usdc`, `base-usdc`, `polygon-usdc`, `ethereum-usdc`, `ethereum-usdt`, `plasma-usdt`, `tron-usdt`, or `all` +- A bare chain name selects every target on that chain: "test ethereum" → `ethereum-usdc` + `ethereum-usdt`; "test plasma" → `plasma-usdt` +- A bare asset name selects every target for that asset: "run USDT tests" → `ethereum-usdt` + `plasma-usdt` + `tron-usdt` +- Chain + asset selects one: "test USDT on eth" → `ethereum-usdt` +- Multiple targets: "test solana and base", "test USDT on plasma and tron" + +**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 Target + +For each target the user wants to test, set the target-specific variables and verify prerequisites. + +### Target Configuration Lookup + +**Network-independent variables:** + +| Target | `WALLET_TYPE` | `STABLE_ASSET` | `STABLE_CURRENCY` | `HELPER_SCRIPT` | `GAS_CMD` | `GAS_TOKEN` | `GAS_MIN` | `TRANSFER_OUT_AMT` | `PIP_DEPS` | +|---|---|---|---|---|---|---|---|---|---| +| `solana-usdc` | `SOLANA_WALLET` | `usdc` | `USDC` | `scripts/solana_helper.py` | `sol-balance` | SOL | 0.1 | 100000 | `solders solana base58` | +| `base-usdc` | `BASE_WALLET` | `usdc` | `USDC` | `scripts/base_helper.py` | `eth-balance` | ETH | 0.001 | 200000 | `web3` | +| `polygon-usdc` | `POLYGON_WALLET` | `usdc` | `USDC` | `scripts/polygon_helper.py` | `pol-balance` | POL | 0.1 | 200000 | `web3` | +| `ethereum-usdc` | `ETHEREUM_WALLET` | `usdc` | `USDC` | `scripts/ethereum_helper.py` | `eth-balance` | ETH | 0.01 | 200000 | `web3` | +| `ethereum-usdt` | `ETHEREUM_WALLET` | `usdt` | `USDT` | `scripts/ethereum_helper.py` | `eth-balance` | ETH | 0.01 | 200000 | `web3` | +| `plasma-usdt` | `PLASMA_WALLET` | `usdt` | `USDT` | `scripts/plasma_helper.py` | `xpl-balance` | XPL | 0.01 | 200000 | `web3` | +| `tron-usdt` | `TRON_WALLET` | `usdt` | `USDT` | `scripts/tron_helper.py` | `trx-balance` | TRX | 50 | 200000 | `tronpy` | + +**Network-dependent variables** (pick the column pair matching the mode detected in Step 3): + +| Target | `CRYPTO_NETWORK` (testnet) | `CRYPTO_NETWORK` (mainnet) | `CRED_KEY` (testnet) | `CRED_KEY` (mainnet) | +|---|---|---|---|---| +| `solana-usdc` | `SOLANA_DEVNET` | `SOLANA_MAINNET` | `solanaDevnetPrivateKey` | `solanaMainnetPrivateKey` | +| `base-usdc` | `BASE_TESTNET` | `BASE_MAINNET` | `baseTestnetPrivateKey` | `baseMainnetPrivateKey` | +| `polygon-usdc` | `POLYGON_TESTNET` | `POLYGON_MAINNET` | `polygonTestnetPrivateKey` | `polygonMainnetPrivateKey` | +| `ethereum-usdc` | `ETHEREUM_TESTNET` | `ETHEREUM_MAINNET` | `ethereumTestnetPrivateKey` | `ethereumMainnetPrivateKey` | +| `ethereum-usdt` | `ETHEREUM_TESTNET` | `ETHEREUM_MAINNET` | `ethereumTestnetPrivateKey` | `ethereumMainnetPrivateKey` | +| `plasma-usdt` | `PLASMA` | `PLASMA` | `plasmaTestnetPrivateKey` | `plasmaMainnetPrivateKey` | +| `tron-usdt` | `TRON_TESTNET` | `TRON_MAINNET` | `tronTestnetPrivateKey` | `tronMainnetPrivateKey` | + +`STABLE_ASSET` is the lowercase asset name used in helper subcommands (`$STABLE_ASSET-balance`, `send-$STABLE_ASSET`). `STABLE_CURRENCY` is the uppercase Grid currency code used in API request bodies. Each helper script resolves the right token contract from its own subcommand name, so `ethereum-usdc` and `ethereum-usdt` differ only in `STABLE_ASSET` / `STABLE_CURRENCY`. + +**On the `_TESTNET` / `_MAINNET` suffixes:** the spec's `CryptoNetwork` enum contains only bare names (`ETHEREUM`, `SOLANA`, `PLASMA`, …) and says the platform's environment decides testnet vs mainnet. The suffixed values above are accepted as aliases — `ETHEREUM` and `ETHEREUM_TESTNET` resolve to the same network — so they are kept for continuity with earlier runs. `plasma-usdt` uses the bare `PLASMA` in both columns because no suffixed variant has been confirmed to work. + +**`plasma-usdt` readiness (as of 2026-08-03):** the backend implementation is complete on `webdev` `main` (commit `68b5932440`, 2026-07-31, "Enable USDT on Plasma customer-facing"), but production had not picked it up yet. Against a pre-Plasma build, external-account creation fails with `MISSING_MANDATORY_USER_INFO: Beneficiary information is required for fiat accounts` — `PlasmaWalletExternalAccountInfo` is absent from that build's `CryptoExternalAccountInfo` union, so the request is classified as fiat. + +Plasma is additionally gated behind `GK.USDT_PLASMA_ENABLED`, per platform. Use the error to tell the three states apart: + +| Response | Meaning | +|---|---| +| `MISSING_MANDATORY_USER_INFO: Beneficiary information is required for fiat accounts` | Deployed build predates Plasma support | +| `INVALID_INPUT: USDT on Plasma is not enabled for this platform.` (external-account create) or `INVALID_INPUT: USDT on Plasma is not enabled.` (quotes) | Build is current; gatekeeper is off for this platform | +| `201` with an account id | Ready — run the target | + +Note that Plasma carries USDT only; a USDC Plasma account is rejected by design. Probe before running: + +```bash +curl -s -u "$GRID_API_TOKEN_ID:$GRID_API_CLIENT_SECRET" \ + -X POST -H "Content-Type: application/json" \ + -d "{\"customerId\":\"$CUSTOMER_ID\",\"currency\":\"USDT\",\"cryptoNetwork\":\"PLASMA\",\"accountInfo\":{\"accountType\":\"PLASMA_WALLET\",\"address\":\"$WALLET_ADDRESS\"}}" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Skip the target on either error, reporting which of the two states above it matched. Everything else about the target is wired and runs unchanged once the deploy lands and the gatekeeper is on. + +### Per-target prerequisites + +For each selected target, run these checks. Skip a target (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 target. Targets sharing a chain share a key — a missing `ethereumTestnetPrivateKey` skips both `ethereum-usdc` and `ethereum-usdt`. + +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-$STABLE_ASSET --to ...`. All helper scripts accept `--mainnet` to switch RPC endpoints, chain IDs, token 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/ + - Ethereum: https://www.alchemy.com/faucets/ethereum-sepolia + - Plasma: https://faucet.plasma.to/ (Plasma testnet XPL faucet) + - Tron: https://shasta.tronex.io/ (Shasta testnet TRX faucet) + +5. **Check stablecoin balance:** + ```bash + chain_helper $STABLE_ASSET-balance + ``` + If `amount` < 1.0, warn the user. Testnet stablecoin sources: + - `solana-usdc`: Solana devnet USDC faucet + - `base-usdc`: https://faucet.circle.com/ (select Base Sepolia) + - `polygon-usdc`: https://faucet.circle.com/ (select Polygon Amoy) + - `ethereum-usdc`: https://faucet.circle.com/ (select Ethereum Sepolia) + - `ethereum-usdt`: no public faucet. Acquire Sepolia USDT (ERC-20 contract `0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0`, 6 decimals) by transferring from an existing test wallet or swapping on a Sepolia DEX. + - `plasma-usdt`: https://faucet.plasma.to/ (Plasma testnet faucet — dispenses XPL; for testnet USDT0, contract `0x502012b361AebCE43b26Ec812B74D9a51dB4D412`, transfer from an existing test wallet if the faucet does not dispense it) + - `tron-usdt`: https://shasta.tronex.io/ (Shasta testnet faucet — request TRX, then swap or fund via the TRC-20 USDT contract `TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs`) + +6. **Get wallet address:** + ```bash + chain_helper wallet-address + ``` + Save as `WALLET_ADDRESS` for this target. Targets sharing a chain also share a wallet address. + +## Step 5: Run Tests + +Read `references/test-catalog.md` for detailed test steps. Each test is parameterized by target variables set in Step 4. Run tests sequentially within each target (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 the target's stablecoin in the internal account) +- Tests 7, 11 need USD balance — either sandbox fund endpoint or a prior stablecoin→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-target execution:** Run each target fully before moving to the next. Set `CHAIN_PREFIX` per target for unique customer IDs: +- `solana-usdc`: `CHAIN_PREFIX="solana-usdc-test"` +- `base-usdc`: `CHAIN_PREFIX="base-usdc-test"` +- `polygon-usdc`: `CHAIN_PREFIX="polygon-usdc-test"` +- `ethereum-usdc`: `CHAIN_PREFIX="ethereum-usdc-test"` +- `ethereum-usdt`: `CHAIN_PREFIX="ethereum-usdt-test"` +- `plasma-usdt`: `CHAIN_PREFIX="plasma-usdt-test"` +- `tron-usdt`: `CHAIN_PREFIX="tron-usdt-test"` + +Each target gets its own customer, so `ethereum-usdc` and `ethereum-usdt` never share internal-account state. They do share one on-chain wallet, so run them sequentially — concurrent sends from the same address collide on the nonce. + +## Step 6: Results Summary + +After all tests complete, print a results table per target: + +``` +## solana-usdc Results +| # | Test Case | Status | Details | +|---|---------------------------------------------------|--------|---------| +| 1 | Customer + Stablecoin Account Creation | PASS | ... | +| 2 | Fund Internal Account (deposit) | PASS | ... | +| 3 | Transfer Out (→ wallet) | PASS | ... | +| 4 | Stablecoin → USD (RT funded → internal) | PASS | ... | +| 5 | Stablecoin → USD (RT funded → external bank) | PASS | ... | +| 6 | Stablecoin → MXN (RT funded → CLABE) | PASS | ... | +| 7 | USD → Stablecoin (Account funded → wallet) | PASS | ... | +| 8 | Stablecoin → USD (Account funded → internal) | PASS | ... | +| 9 | Stablecoin → MXN (Account funded → CLABE) | PASS | ... | +| 10 | Stablecoin → USD (RT funded → UMA) | PASS | ... | +| 11 | USD → USD (Account funded → UMA) | PASS | ... | + +("Stablecoin" is the target's `STABLE_CURRENCY` — substitute USDC or USDT in the actual output.) + +## ethereum-usdt Results +... + +## tron-usdt Results +... +``` + +Include in Details: amounts, transaction IDs, error messages, or timing. + +If multiple targets were tested, add an aggregate summary: + +``` +## Summary +| Target | Passed | Failed | Skipped | +|---------------|--------|--------|---------| +| solana-usdc | 11/11 | 0 | 0 | +| base-usdc | 10/11 | 1 | 0 | +| polygon-usdc | 0/11 | 0 | 11 | +| ethereum-usdc | 11/11 | 0 | 0 | +| ethereum-usdt | 11/11 | 0 | 0 | +| plasma-usdt | 11/11 | 0 | 0 | +| tron-usdt | 11/11 | 0 | 0 | +``` + +## 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-$STABLE_ASSET` fails, check gas balance (may need airdrop/faucet) and the target's stablecoin 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. Amounts are denominated in the target's stablecoin at 6 decimals — every supported stablecoin uses 6 decimals on every supported chain, so the raw numbers below hold for all targets. + +| Test | Amount | Notes | +|------|--------|-------| +| 2 (deposit) | 0.50 stablecoin (500000) | | +| 3 (transfer-out) | `solana-usdc`: 0.10 (100000), all others: 0.20 (200000) | EVM chains must exceed ~100100 custody fee; Tron mirrors the EVM minimum | +| 4-5 (Stablecoin→USD RT) | $0.10 locked receiving (10 cents) | | +| 6 (Stablecoin→MXN RT) | 11.00 MXN locked receiving (1100 centavos, ~$0.55) | Some envs enforce 1100 minimum | +| 7 (USD→Stablecoin) | $0.50 sending (50 cents) | Requires sandbox or prior USD balance | +| 8 (Stablecoin→USD acct) | 0.05 stablecoin sending (50000) | Requires stablecoin from test 2 | +| 9 (Stablecoin→MXN acct) | 0.05 stablecoin sending (50000) | Requires stablecoin from test 2 | +| 10 (Stablecoin→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 target: ~1.3-1.5 stablecoin units + gas fees.** Running `ethereum-usdc` and `ethereum-usdt` together therefore needs ~1.5 USDC *and* ~1.5 USDT in the same wallet, plus enough ETH for both runs. + +## 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", + "ethereumTestnetPrivateKey": "hex-private-key-with-or-without-0x", + "ethereumMainnetPrivateKey": "hex-private-key-with-or-without-0x", + "plasmaTestnetPrivateKey": "hex-private-key-with-or-without-0x", + "plasmaMainnetPrivateKey": "hex-private-key-with-or-without-0x", + "tronTestnetPrivateKey": "hex-private-key-with-or-without-0x", + "tronMainnetPrivateKey": "hex-private-key-with-or-without-0x" +} +``` + +Only the keys for targets you want to test are required. The skill auto-skips targets without keys. One key covers every target on its chain — `ethereumTestnetPrivateKey` serves both `ethereum-usdc` and `ethereum-usdt`. + +## Token Contracts + +Each helper hardcodes the token contracts it sends and reads. For reference: + +| Target | Testnet contract | Mainnet contract | +|---|---|---| +| `ethereum-usdc` | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` (Sepolia) | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +| `ethereum-usdt` | `0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0` (Sepolia) | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | +| `plasma-usdt` | `0x502012b361AebCE43b26Ec812B74D9a51dB4D412` | `0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb` | +| `tron-usdt` | `TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs` (Shasta) | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | + +Both Plasma contracts report `symbol()` as `USDT0` (Plasma's USD₮0 branding) with 6 decimals. Grid treats them as `USDT` — `STABLE_CURRENCY` stays `USDT` in all API bodies. + +If a deposit never lands despite a confirmed on-chain send, the most likely cause is a contract mismatch — Grid indexes a different token contract than the helper sent to. Verify against Grid's configured contract for that network before debugging further. 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 000000000..c3bda0e4c --- /dev/null +++ b/.claude/skills/grid-test/references/test-catalog.md @@ -0,0 +1,722 @@ +# Test Catalog + +All tests use these target variables (set per-target in SKILL.md Step 4). A target is a chain paired with a stablecoin, e.g. `ethereum-usdc` or `ethereum-usdt`. + +- `chain_helper` — shell function wrapping the target's helper script (defined in SKILL.md Step 4) +- `$CRYPTO_NETWORK` — e.g., `SOLANA_DEVNET`, `BASE_TESTNET`, `POLYGON_TESTNET`, `ETHEREUM_TESTNET`, `PLASMA`, `TRON_TESTNET` +- `$WALLET_TYPE` — e.g., `SOLANA_WALLET`, `BASE_WALLET`, `POLYGON_WALLET`, `ETHEREUM_WALLET`, `PLASMA_WALLET`, `TRON_WALLET` +- `$WALLET_ADDRESS` — the test wallet's on-chain address +- `$STABLE_ASSET` — lowercase stablecoin name for helper subcommands (`usdc` or `usdt`) +- `$STABLE_CURRENCY` — uppercase Grid currency code for API bodies (`USDC` or `USDT`) +- `$TRANSFER_OUT_AMT` — target-specific minimum transfer-out amount +- `$IS_SANDBOX` — whether the platform is sandbox +- `$CHAIN_PREFIX` — unique prefix for this target run (e.g., `solana-usdc-test`, `ethereum-usdt-test`, `plasma-usdt-test`, `tron-usdt-test`) + +Shell variables referenced below (`STABLE_INTERNAL_ID`, `STABLE_EXTERNAL_ID`, etc.) hold the IDs of internal/external accounts denominated in `$STABLE_CURRENCY`. + +On a chain that supports two stablecoins, `$WALLET_TYPE` is identical across both targets — the account's asset is carried by `$STABLE_CURRENCY` in the request body, and by `assetType` on the wallet info Grid returns. When extracting a deposit or payment address from `fundingPaymentInstructions` / `paymentInstructions`, match on **both** `accountOrWalletInfo.accountType == $WALLET_TYPE` **and** `accountOrWalletInfo.assetType == $STABLE_CURRENCY`, or you may pick up the sibling asset's address on Ethereum. + +API credentials are in environment variables: `$GRID_API_TOKEN_ID`, `$GRID_API_CLIENT_SECRET`, `$GRID_BASE_URL`. + +--- + +## Test 1: Customer + Stablecoin Account Creation + +**Goal:** Create a customer and verify the target's stablecoin (`$STABLE_CURRENCY`) internal account exists with wallet funding instructions for this target's chain. + +**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: + - `STABLE_INTERNAL_ID`: the `id` of the internal account where `balance.currency` matches `$STABLE_CURRENCY` + - `USD_INTERNAL_ID`: the `id` of the internal account where `balance.currency` is `USD` + - `DEPOSIT_ADDRESS`: from the stablecoin account's `fundingPaymentInstructions` array, find the entry where `accountOrWalletInfo.accountType` is `$WALLET_TYPE` and `accountOrWalletInfo.assetType` is `$STABLE_CURRENCY`, then extract `accountOrWalletInfo.address` + +4. **PASS criteria:** + - Customer created successfully + - `$STABLE_CURRENCY` internal account exists + - `fundingPaymentInstructions` contains a `$WALLET_TYPE` / `$STABLE_CURRENCY` entry with a non-empty `address` + +--- + +## Test 2: Fund Internal Account with Real Testnet Stablecoin + +**Goal:** Send real stablecoin (`$STABLE_CURRENCY`) on the chain's testnet and verify Grid detects the deposit. + +**Steps:** + +1. Record initial stablecoin 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=$STABLE_CURRENCY" +``` + +Save initial `balance.amount` as `INITIAL_STABLE_BALANCE`. + +2. Send 0.50 of the stablecoin to the deposit address: + +```bash +chain_helper send-$STABLE_ASSET --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=$STABLE_CURRENCY" +``` + +4. **PASS criteria:** `$STABLE_CURRENCY` internal account `balance.amount` increases above `INITIAL_STABLE_BALANCE`. + +--- + +## Test 3: Transfer Out (stablecoin internal -> external wallet) + +**Goal:** Withdraw the target's stablecoin from the 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\": \"$STABLE_CURRENCY\", + \"cryptoNetwork\": \"$CRYPTO_NETWORK\", + \"accountInfo\": { + \"accountType\": \"$WALLET_TYPE\", + \"address\": \"$WALLET_ADDRESS\" + } + }" \ + "$GRID_BASE_URL/customers/external-accounts" +``` + +Save the `id` as `STABLE_EXTERNAL_ID`. + +2. Record initial on-chain stablecoin balance: + +```bash +chain_helper $STABLE_ASSET-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_STABLE`. + +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\": \"$STABLE_INTERNAL_ID\"}, + \"destination\": {\"accountId\": \"$STABLE_EXTERNAL_ID\"}, + \"amount\": $TRANSFER_OUT_AMT + }" \ + "$GRID_BASE_URL/transfer-out" +``` + +4. Poll on-chain stablecoin balance every 5 seconds, up to 120 seconds: + +```bash +chain_helper $STABLE_ASSET-balance +``` + +5. **PASS criteria:** On-chain stablecoin balance (`raw`) increases above `INITIAL_ONCHAIN_STABLE`. + +--- + +## Test 4: Stablecoin -> USD Quote (Real-Time Funded -> internal USD account) + +**Goal:** Use real-time funding to convert the target's stablecoin 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\": \"$STABLE_CURRENCY\", + \"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 entry matching both `$WALLET_TYPE` and `assetType == $STABLE_CURRENCY`, then extract `accountOrWalletInfo.address` + - `TOTAL_SENDING_AMOUNT`: the `totalSendingAmount` field (smallest-unit stablecoin 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 the stablecoin to the payment instructions address: + +```bash +chain_helper send-$STABLE_ASSET --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: Stablecoin -> USD Quote (Real-Time Funded -> external USD bank account) + +**Goal:** Convert the target's stablecoin 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\": \"$STABLE_CURRENCY\", + \"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 the stablecoin to the payment instructions address: + +```bash +chain_helper send-$STABLE_ASSET --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: Stablecoin -> MXN Quote (Real-Time Funded -> external MXN CLABE account) + +**Goal:** Convert the target's stablecoin 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\": \"646180000118359710\", + \"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`. + +The CLABE above uses bank code `646` (STP). The previous constant `032180000118359719` (Accendo) has a valid check digit but is rejected with `INVALID_INPUT: Invalid CLABE format or check digit` on environments whose bank registry omits `032`. If `646` is ever rejected too, any mainstream bank code with a correct check digit works — `002`, `012`, `014`, `021`, `072`, and `127` were all accepted on 2026-08-16. + +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\": \"$STABLE_CURRENCY\", + \"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 the stablecoin: + +```bash +chain_helper send-$STABLE_ASSET --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 -> Stablecoin Quote (Account-Funded -> external wallet) + +**Goal:** Convert USD from internal account to the target's stablecoin 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 stablecoin->USD conversion (Test 4) or manual funding." If balance > 0, proceed. + +2. Record initial on-chain stablecoin balance: + +```bash +chain_helper $STABLE_ASSET-balance +``` + +Save `raw` as `INITIAL_ONCHAIN_STABLE_T7`. + +3. Ensure the stablecoin external account exists (reuse `$STABLE_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\": \"$STABLE_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 stablecoin balance every 5 seconds, up to 120 seconds: + +```bash +chain_helper $STABLE_ASSET-balance +``` + +7. **PASS criteria:** On-chain stablecoin balance (`raw`) increases above `INITIAL_ONCHAIN_STABLE_T7`. + +--- + +## Test 8: Stablecoin -> USD Quote (Account-Funded -> internal USD account) + +**Goal:** Convert the target's stablecoin from the internal account to USD in the customer's internal USD account, using the existing stablecoin balance (no real-time funding). + +**Steps:** + +1. Check stablecoin 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=$STABLE_CURRENCY" +``` + +If `balance.amount` is 0, skip this test with: "SKIP: No $STABLE_CURRENCY 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\": \"$STABLE_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: Stablecoin -> MXN Quote (Account-Funded -> external MXN CLABE account) + +**Goal:** Convert the target's stablecoin from the internal account to MXN and send to a Mexican bank account, using the existing stablecoin balance. + +**Steps:** + +1. Check stablecoin 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=$STABLE_CURRENCY" +``` + +If `balance.amount` is 0, skip this test with: "SKIP: No $STABLE_CURRENCY 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\": \"646180000118359710\", + \"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\": \"$STABLE_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: Stablecoin -> USD Quote (Real-Time Funded -> UMA address) + +**Goal:** Use real-time stablecoin 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\": \"$STABLE_CURRENCY\", + \"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 entry matching both `$WALLET_TYPE` and `assetType == $STABLE_CURRENCY`, then extract `accountOrWalletInfo.address` + - `TOTAL_SENDING_AMOUNT`: the `totalSendingAmount` field + +4. Send the stablecoin to the payment instructions address: + +```bash +chain_helper send-$STABLE_ASSET --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 000000000..724e841cf --- /dev/null +++ b/.claude/skills/grid-test/scripts/base_helper.py @@ -0,0 +1,217 @@ +#!/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) + + # maxFeePerGas must never fall below maxPriorityFeePerGas, or the node + # rejects the tx outright. Deriving it from the block's base fee keeps + # that true even when base fee is far below the priority tip, which is + # routine on low-fee chains during quiet periods. + priority_fee = w3.to_wei(NET["priority_fee_gwei"], "gwei") + base_fee = w3.eth.get_block("latest").get("baseFeePerGas") or w3.eth.gas_price + max_fee = base_fee * 2 + priority_fee + + tx = usdc.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority_fee, + }) + + 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/ethereum_helper.py b/.claude/skills/grid-test/scripts/ethereum_helper.py new file mode 100644 index 000000000..912ca1659 --- /dev/null +++ b/.claude/skills/grid-test/scripts/ethereum_helper.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Ethereum L1 CLI for Grid USDC and USDT 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) + usdt-balance [--address] Print USDT balance (raw + human-readable) + send-usdc --to --amount Send USDC (amount in micro-USDC, 6 decimals) + send-usdt --to --amount Send USDT (amount in micro-USDT, 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, + "tokens": { + "usdc": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "usdt": "0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0", + }, + "cred_key": "ethereumTestnetPrivateKey", + "name": "Ethereum Sepolia", + "priority_fee_gwei": 1.5, + }, + "mainnet": { + "rpc": "https://ethereum-rpc.publicnode.com", + "chain_id": 1, + "tokens": { + "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "usdt": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + }, + "cred_key": "ethereumMainnetPrivateKey", + "name": "Ethereum Mainnet", + "priority_fee_gwei": 1.5, + }, +} + +TOKEN_DECIMALS = 6 + +# Mainnet USDT's transfer() returns nothing rather than a bool, but web3 only +# encodes calldata when building a signed transaction, so this ABI serves both. +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 token_address(asset): + return NET["tokens"][asset] + + +def get_token_contract(w3, asset): + return w3.eth.contract(address=Web3.to_checksum_address(token_address(asset)), abi=ERC20_ABI) + + +def resolve_address(args): + if args.address: + return Web3.to_checksum_address(args.address) + return load_account().address + + +def cmd_wallet_address(args): + acct = load_account() + print(json.dumps({"address": acct.address})) + + +def cmd_eth_balance(args): + w3 = get_web3() + address = resolve_address(args) + 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_token_balance(asset): + def run(args): + w3 = get_web3() + token = get_token_contract(w3, asset) + address = resolve_address(args) + raw = token.functions.balanceOf(address).call() + print(json.dumps({ + "address": address, + "asset": asset.upper(), + "contract": token_address(asset), + "raw": raw, + "amount": raw / (10 ** TOKEN_DECIMALS), + "ui_amount": f"{raw / (10 ** TOKEN_DECIMALS):.6f}", + })) + return run + + +def cmd_send_token(asset): + def run(args): + acct = load_account() + w3 = get_web3() + token = get_token_contract(w3, asset) + recipient = Web3.to_checksum_address(args.to) + amount = int(args.amount) + + nonce = w3.eth.get_transaction_count(acct.address) + + # maxFeePerGas must never fall below maxPriorityFeePerGas, or the node + # rejects the tx outright. Deriving it from the block's base fee keeps + # that true even when base fee is far below the priority tip, which is + # routine on mainnet during quiet periods. + priority_fee = w3.to_wei(NET["priority_fee_gwei"], "gwei") + base_fee = w3.eth.get_block("latest").get("baseFeePerGas") or w3.eth.gas_price + max_fee = base_fee * 2 + priority_fee + + tx = token.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority_fee, + }) + + 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", "asset": asset.upper(), "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", "asset": asset.upper(), "tx_hash": tx_hash_hex, "block": receipt["blockNumber"]})) + return + else: + print(json.dumps({"status": "failed", "asset": asset.upper(), "tx_hash": tx_hash_hex, "receipt_status": receipt["status"]})) + sys.exit(1) + except Exception: + pass + + print(json.dumps({"status": "timeout", "asset": asset.upper(), "tx_hash": tx_hash_hex, "message": "Transaction sent but confirmation timed out."})) + sys.exit(1) + return run + + +def main(): + global NET + + parser = argparse.ArgumentParser(description="Ethereum L1 helper for Grid USDC/USDT 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)") + + dispatch = { + "wallet-address": cmd_wallet_address, + "eth-balance": cmd_eth_balance, + } + + for asset in ("usdc", "usdt"): + upper = asset.upper() + + bal = sub.add_parser(f"{asset}-balance", help=f"Print {upper} balance") + bal.add_argument("--address", help="Address to check (default: own wallet)") + dispatch[f"{asset}-balance"] = cmd_token_balance(asset) + + send = sub.add_parser(f"send-{asset}", help=f"Send {upper}") + send.add_argument("--to", required=True, help="Recipient address (0x...)") + send.add_argument("--amount", required=True, help=f"Amount in micro-{upper} (6 decimals)") + dispatch[f"send-{asset}"] = cmd_send_token(asset) + + args = parser.parse_args() + NET = NETWORKS["mainnet"] if args.mainnet else NETWORKS["testnet"] + + dispatch[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/grid-test/scripts/plasma_helper.py b/.claude/skills/grid-test/scripts/plasma_helper.py new file mode 100755 index 000000000..3b3d4559b --- /dev/null +++ b/.claude/skills/grid-test/scripts/plasma_helper.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Plasma chain CLI for Grid USDT testing. + +Supports both Plasma testnet (default) and Plasma mainnet (--mainnet flag). + +Plasma carries USDT only — Grid's PaymentPlasmaWalletInfo declares +`assetType: [USDT]`, so unlike ethereum_helper.py there is no USDC path here. +The mainnet token is branded USD0 (symbol `USDT0`) but is the USDT Grid indexes +for the PLASMA network. + +Subcommands: + wallet-address Print public address of loaded key + xpl-balance [--address] Print XPL balance + usdt-balance [--address] Print USDT balance (raw + human-readable) + send-usdt --to --amount Send USDT (amount in micro-USDT, 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://testnet-rpc.plasma.to", + "chain_id": 9746, + "usdt_contract": "0x502012b361AebCE43b26Ec812B74D9a51dB4D412", + "cred_key": "plasmaTestnetPrivateKey", + "name": "Plasma Testnet", + "priority_fee_gwei": 1, + }, + "mainnet": { + "rpc": "https://rpc.plasma.to", + "chain_id": 9745, + "usdt_contract": "0xb8ce59fc3717ada4c02eadf9682a9e934f625ebb", + "cred_key": "plasmaMainnetPrivateKey", + "name": "Plasma Mainnet", + "priority_fee_gwei": 1, + }, +} + +USDT_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 token_address(): + contract = NET["usdt_contract"] + if not contract: + print(json.dumps({ + "error": f"No USDT contract configured for {NET['name']}", + "detail": "Set NETWORKS['testnet']['usdt_contract'] in plasma_helper.py to the address Grid indexes for PLASMA on testnet, or run against mainnet with --mainnet.", + })) + sys.exit(1) + return contract + + +def get_usdt_contract(w3): + return w3.eth.contract(address=Web3.to_checksum_address(token_address()), abi=ERC20_ABI) + + +def resolve_address(args): + if args.address: + return Web3.to_checksum_address(args.address) + return load_account().address + + +def cmd_wallet_address(args): + acct = load_account() + print(json.dumps({"address": acct.address})) + + +def cmd_xpl_balance(args): + w3 = get_web3() + address = resolve_address(args) + balance_wei = w3.eth.get_balance(address) + print(json.dumps({ + "address": address, + "wei": balance_wei, + "xpl": float(Web3.from_wei(balance_wei, "ether")), + })) + + +def cmd_usdt_balance(args): + w3 = get_web3() + usdt = get_usdt_contract(w3) + address = resolve_address(args) + raw = usdt.functions.balanceOf(address).call() + print(json.dumps({ + "address": address, + "asset": "USDT", + "contract": token_address(), + "raw": raw, + "amount": raw / (10 ** USDT_DECIMALS), + "ui_amount": f"{raw / (10 ** USDT_DECIMALS):.6f}", + })) + + +def cmd_send_usdt(args): + acct = load_account() + w3 = get_web3() + usdt = get_usdt_contract(w3) + recipient = Web3.to_checksum_address(args.to) + amount = int(args.amount) + + nonce = w3.eth.get_transaction_count(acct.address) + + # maxFeePerGas must never fall below maxPriorityFeePerGas, or the node + # rejects the tx outright. Deriving it from the block's base fee keeps + # that true even when base fee is far below the priority tip, which is + # routine on low-fee chains during quiet periods. + priority_fee = w3.to_wei(NET["priority_fee_gwei"], "gwei") + base_fee = w3.eth.get_block("latest").get("baseFeePerGas") or w3.eth.gas_price + max_fee = base_fee * 2 + priority_fee + + tx = usdt.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority_fee, + }) + + 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", "asset": "USDT", "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", "asset": "USDT", "tx_hash": tx_hash_hex, "block": receipt["blockNumber"]})) + return + else: + print(json.dumps({"status": "failed", "asset": "USDT", "tx_hash": tx_hash_hex, "receipt_status": receipt["status"]})) + sys.exit(1) + except Exception: + pass + + print(json.dumps({"status": "timeout", "asset": "USDT", "tx_hash": tx_hash_hex, "message": "Transaction sent but confirmation timed out."})) + sys.exit(1) + + +def main(): + global NET + + parser = argparse.ArgumentParser(description="Plasma chain helper for Grid USDT testing") + parser.add_argument("--mainnet", action="store_true", help="Use Plasma mainnet instead of testnet") + sub = parser.add_subparsers(dest="command") + sub.required = True + + sub.add_parser("wallet-address", help="Print public address of loaded key") + + xpl_bal = sub.add_parser("xpl-balance", help="Print XPL balance") + xpl_bal.add_argument("--address", help="Address to check (default: own wallet)") + + usdt_bal = sub.add_parser("usdt-balance", help="Print USDT balance") + usdt_bal.add_argument("--address", help="Address to check (default: own wallet)") + + send = sub.add_parser("send-usdt", help="Send USDT") + send.add_argument("--to", required=True, help="Recipient address (0x...)") + send.add_argument("--amount", required=True, help="Amount in micro-USDT (6 decimals)") + + args = parser.parse_args() + NET = NETWORKS["mainnet"] if args.mainnet else NETWORKS["testnet"] + + dispatch = { + "wallet-address": cmd_wallet_address, + "xpl-balance": cmd_xpl_balance, + "usdt-balance": cmd_usdt_balance, + "send-usdt": cmd_send_usdt, + } + 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 000000000..d9c4f0617 --- /dev/null +++ b/.claude/skills/grid-test/scripts/polygon_helper.py @@ -0,0 +1,217 @@ +#!/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": "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", + "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) + + # maxFeePerGas must never fall below maxPriorityFeePerGas, or the node + # rejects the tx outright. Deriving it from the block's base fee keeps + # that true even when base fee is far below the priority tip, which is + # routine on low-fee chains during quiet periods. + priority_fee = w3.to_wei(NET["priority_fee_gwei"], "gwei") + base_fee = w3.eth.get_block("latest").get("baseFeePerGas") or w3.eth.gas_price + max_fee = base_fee * 2 + priority_fee + + tx = usdc.functions.transfer(recipient, amount).build_transaction({ + "chainId": NET["chain_id"], + "from": acct.address, + "nonce": nonce, + "gas": 100_000, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority_fee, + }) + + 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 000000000..3b2498c42 --- /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("