Agent Public Payment — Local-Signed Payment Workflow

Full AI automation for X402 cross-chain payments using AgentTech SDK in public mode - generate wallets locally, sign X402 authorization locally, create intents, submit proofs, and poll status via SDK. No API key required, full control over private keys.

This skill enables AI agents to complete cross-chain USDC payments end-to-end without a browser or human: generate wallets locally, create a payment intent via the SDK, sign the X402 authorization with your local key, submit the proof, and poll until settled. Merchants always receive USDC on Base.

Use Case: Choose this mode when you need full control over private keys and want to sign transactions locally. No API key required - perfect for agents that need direct control over signing and wallet management. Private keys never leave your machine.

SDK Support: This skill uses the AgentTech SDK (JavaScript/TypeScript and Go) in public mode (PublicPayClient). No API key required - you sign X402 proofs locally.


JSON Schema Definition

{
  "name": "agent_public_payment",
  "description": "Complete end-to-end X402 cross-chain payment automation for AI agents using AgentTech SDK in public mode. Generates wallets locally, creates intents, signs X402 proofs locally, submits proofs, and polls for completion. No API key required, full control over private keys.",
  "input_schema": {
    "type": "object",
    "properties": {
      "recipient": {
        "type": "string",
        "description": "Recipient wallet address (Base 0x address) or email address"
      },
      "email": {
        "type": "string",
        "description": "Recipient email address (alternative to recipient)"
      },
      "amount": {
        "type": "string",
        "description": "USDC amount as string (e.g. '10.50'). Minimum: 0.01, Maximum: 1,000,000. Up to 6 decimal places."
      },
      "payer_chain": {
        "type": "string",
        "description": "Source chain identifier. See Supported Chains documentation for the full list of supported chains."
      },
      "wallet_type": {
        "type": "string",
        "enum": ["evm", "solana"],
        "description": "Type of wallet to use for signing (must match payer_chain)"
      }
    },
    "required": ["amount", "payer_chain", "wallet_type"],
    "oneOf": [
      { "required": ["email"] },
      { "required": ["recipient"] }
    ]
  },
  "output_schema": {
    "type": "object",
    "properties": {
      "intent_id": {
        "type": "string",
        "description": "Unique identifier for the created intent"
      },
      "status": {
        "type": "string",
        "enum": ["BASE_SETTLED", "EXPIRED", "VERIFICATION_FAILED"],
        "description": "Final status of the payment"
      },
      "transaction_hash": {
        "type": "string",
        "description": "Transaction hash on Base chain (available when status is BASE_SETTLED)"
      },
      "payer_wallet": {
        "type": "object",
        "description": "Generated wallet information"
      }
    },
    "required": ["intent_id", "status"]
  }
}

Quick Start (4 Steps)

  1. Generate and save wallets (EVM for Base, Solana) — see Step 1: Generate Wallets.
  2. Create payment intent — Use SDK createIntent() with email or recipient, amount, payer_chain → get intent_id and payment_requirements.
  3. Sign X402 locally using payment_requirements and your wallet private key → produce settle_proof (base64 string). See Step 2: Sign and Produce settle_proof.
  4. Submit proof — Use SDK submitProof(intentId, settleProof), then poll getIntent(intentId) until status is BASE_SETTLED or EXPIRED.

Complete Example (TypeScript)

import { PublicPayClient } from '@cross402/usdc';
import { Wallet } from 'ethers';
import { buildEVMsettleProof } from './x402-signing';

async function completeX402Payment(recipient: string, amount: string) {
  // Step 1: Generate or load wallet
  const wallet = Wallet.createRandom();
  const payerAddress = wallet.address;
  const privateKey = wallet.privateKey;

  // Step 2: Initialize SDK client
  const client = new PublicPayClient({
    baseUrl: 'https://api-pay.agent.tech',
  });

  // Step 3: Create intent
  const intent = await client.createIntent({
    recipient,
    amount,
    payerChain: 'base',
  });

  console.log(`Intent created: ${intent.intentId}`);
  console.log(`Payment requirements:`, intent.paymentRequirements);

  // Step 4: Sign X402 proof locally
  const settleProof = buildEVMsettleProof(
    intent.paymentRequirements,
    payerAddress,
    privateKey
  );

  // Step 5: Submit proof
  const result = await client.submitProof(intent.intentId, settleProof);
  console.log(`Proof submitted. Status: ${result.status}`);

  // Step 6: Poll until completion
  let finalIntent = result;
  while (
    finalIntent.status !== 'BASE_SETTLED' &&
    finalIntent.status !== 'PARTIAL_SETTLEMENT' &&
    finalIntent.status !== 'EXPIRED' &&
    finalIntent.status !== 'VERIFICATION_FAILED'
  ) {
    await new Promise(resolve => setTimeout(resolve, 3000));
    finalIntent = await client.getIntent(intent.intentId);
    console.log(`Status: ${finalIntent.status}`);
  }

  if (finalIntent.status === 'BASE_SETTLED') {
    console.log(`Payment complete! Transaction: ${finalIntent.basePayment.txHash}`);
    return {
      success: true,
      intentId: finalIntent.intentId,
      txHash: finalIntent.basePayment.txHash,
    };
  } else {
    throw new Error(`Payment failed: ${finalIntent.status}`);
  }
}

Step 1: Generate Wallets

Generate payer wallets locally. Private keys never leave your machine; the SDK only ever receives a signed settle_proof.

EVM (Base)

TypeScript/JavaScript (ethers):

npm install ethers
import { Wallet } from 'ethers';

const wallet = Wallet.createRandom();
const evmWallet = {
  type: 'evm',
  symbol: 'ETH',
  address: wallet.address,
  private_key: wallet.privateKey,
  mnemonic: wallet.mnemonic?.phrase,
};

console.log(`EVM Address: ${wallet.address}`);
console.log(`Private Key: ${wallet.privateKey}`);

Go:

package main

import (
    "crypto/ecdsa"
    "fmt"
    "github.com/ethereum/go-ethereum/accounts"
    "github.com/ethereum/go-ethereum/crypto"
)

func generateEVMWallet() (string, string, error) {
    privateKey, err := crypto.GenerateKey()
    if err != nil {
        return "", "", err
    }
    
    publicKey := privateKey.Public()
    publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
    if !ok {
        return "", "", fmt.Errorf("error casting public key")
    }
    
    address := crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
    privateKeyHex := fmt.Sprintf("%x", crypto.FromECDSA(privateKey))
    
    return address, privateKeyHex, nil
}

Solana

TypeScript/JavaScript (@solana/web3.js):

npm install @solana/web3.js
import { Keypair } from '@solana/web3.js';
import * as bs58 from 'bs58';

const keypair = Keypair.generate();
const solWallet = {
  type: 'solana',
  symbol: 'SOL',
  address: keypair.publicKey.toBase58(),
  private_key: bs58.encode(keypair.secretKey),
};

console.log(`Solana Address: ${keypair.publicKey.toBase58()}`);

Go:

package main

import (
    "encoding/base64"
    "fmt"
    "github.com/gagliardetto/solana-go"
)

func generateSolanaWallet() (string, string, error) {
    keypair := solana.NewWallet()
    address := keypair.PublicKey().String()
    privateKey := base64.StdEncoding.EncodeToString(keypair.PrivateKey)
    
    return address, privateKey, nil
}

Save Wallets Locally

Store credentials at ~/.config/x402pay/wallets.json (or equivalent).

TypeScript:

import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

function saveWallets(evmWallet: any, solWallet: any) {
  const walletsData = {
    created_at: new Date().toISOString(),
    wallets: [evmWallet, solWallet],
  };
  
  const configDir = path.join(os.homedir(), '.config', 'x402pay');
  fs.mkdirSync(configDir, { recursive: true });
  
  const filePath = path.join(configDir, 'wallets.json');
  fs.writeFileSync(filePath, JSON.stringify(walletsData, null, 2));
  
  console.log(`Wallets saved to ${filePath}`);
}

Go:

package main

import (
    "encoding/json"
    "os"
    "path/filepath"
    "time"
)

type WalletData struct {
    CreatedAt string   `json:"created_at"`
    Wallets   []Wallet `json:"wallets"`
}

func saveWallets(evmWallet, solWallet Wallet) error {
    walletsData := WalletData{
        CreatedAt: time.Now().UTC().Format(time.RFC3339),
        Wallets:   []Wallet{evmWallet, solWallet},
    }
    
    configDir := filepath.Join(os.Getenv("HOME"), ".config", "x402pay")
    os.MkdirAll(configDir, 0755)
    
    filePath := filepath.Join(configDir, "wallets.json")
    data, err := json.MarshalIndent(walletsData, "", "  ")
    if err != nil {
        return err
    }
    
    return os.WriteFile(filePath, data, 0600)
}

Security: Private keys are generated and stored only on your machine. The SDK never receives your private key; it only receives the base64-encoded signed payload (settle_proof).


Step 2: Sign and Produce settle_proof

The SDK expects settle_proof to be exactly: Base64(JSON.stringify(x402_v2_payload)). The backend decodes base64, parses JSON, verifies the payload (including accepted.amount vs intent), and forwards it to the X402 facilitator for settlement. If the format is wrong, verification fails (400).

Always use the payment_requirements returned by createIntent() when building the payload — do not hardcode chain IDs or contract addresses.

Choosing the Signing Method

The payment_requirements returned by createIntent() determines which signing method to use. Check payment_requirements.extra.assetTransferMethod:

extra.assetTransferMethod Signing Method Chains
(absent or undefined) EIP-3009 TransferWithAuthorization Base
"permit2" Permit2 PermitWitnessTransferFrom + EIP-2612 Permit Arbitrum, Polygon, Ethereum, Monad, HyperEVM
(Solana network) Solana VersionedTransaction v0 Solana
function chooseSigningMethod(paymentRequirements: PaymentRequirements): string {
  const network = paymentRequirements.network;

  // Solana chains
  if (network.startsWith('solana:')) {
    return 'solana';
  }

  // EVM chains — check assetTransferMethod
  if (paymentRequirements.extra?.assetTransferMethod === 'permit2') {
    return 'permit2';  // Arbitrum, Polygon, Ethereum, Monad, HyperEVM
  }

  return 'eip3009';  // Base (default)
}

2a. EVM (Base) — EIP-3009 TransferWithAuthorization

TypeScript Example (ethers):

import { Wallet } from 'ethers';
import * as crypto from 'crypto';

interface PaymentRequirements {
  scheme: string;
  network: string;
  amount: string;
  asset: string;
  payTo: string;
  maxTimeoutSeconds: number;
  resource?: string;
  description?: string;
  extra?: {
    name?: string;
    version?: string;
    assetTransferMethod?: 'permit2';  // present for non-Base EVM chains
    feePayer?: string;                // Solana: fee payer public key
    decimals?: number;                // Solana: token decimals
  };
}

function buildEVMsettleProof(
  paymentRequirements: PaymentRequirements,
  payerAddress: string,
  privateKey: string
): string {
  // Parse chainId from CAIP-2 (eip155:8453 -> 8453)
  const network = paymentRequirements.network;
  const chainIdMatch = network.match(/eip155:(\d+)/);
  if (!chainIdMatch) {
    throw new Error(`Invalid network format: ${network}`);
  }
  const chainId = parseInt(chainIdMatch[1], 10);

  const amount = paymentRequirements.amount;
  const payTo = paymentRequirements.payTo;
  const asset = paymentRequirements.asset;
  const extra = paymentRequirements.extra || {};
  const name = extra.name || 'USD Coin';
  const version = extra.version || '2';
  const maxTimeout = paymentRequirements.maxTimeoutSeconds || 600;

  // Generate nonce
  const nonce = '0x' + crypto.randomBytes(32).toString('hex');
  
  // Set validity window
  const now = Math.floor(Date.now() / 1000);
  const validAfter = (now - 600).toString();
  const validBefore = (now + maxTimeout).toString();

  // Build EIP-712 domain
  const domain = {
    name,
    version,
    chainId,
    verifyingContract: asset,
  };

  // Build EIP-712 types
  const types = {
    TransferWithAuthorization: [
      { name: 'from', type: 'address' },
      { name: 'to', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'validAfter', type: 'uint256' },
      { name: 'validBefore', type: 'uint256' },
      { name: 'nonce', type: 'bytes32' },
    ],
  };

  // Build message
  const message = {
    from: payerAddress,
    to: payTo,
    value: amount,
    validAfter,
    validBefore,
    nonce,
  };

  // Sign with ethers
  const wallet = new Wallet(privateKey);
  const signature = wallet.signTypedData(domain, types, message);

  // Build X402 v2 payload
  const payload = {
    x402Version: 2,
    resource: {
      url: paymentRequirements.resource || '/api/intents',
      description: paymentRequirements.description || 'X402 payment',
      mimeType: 'application/json',
    },
    accepted: {
      scheme: paymentRequirements.scheme || 'exact',
      network,
      amount,
      asset,
      payTo,
      maxTimeoutSeconds: maxTimeout,
      extra: paymentRequirements.extra || {},
    },
    payload: {
      signature: signature,
      authorization: {
        from: payerAddress,
        to: payTo,
        value: amount,
        validAfter,
        validBefore,
        nonce,
      },
    },
  };

  // Encode as base64
  return Buffer.from(JSON.stringify(payload)).toString('base64');
}

2b. EVM (Non-Base) — Permit2 + EIP-2612 Gas Sponsoring

For non-Base EVM chains (Arbitrum, Polygon, Ethereum, Monad, HyperEVM), the backend returns payment_requirements.extra.assetTransferMethod: "permit2". This flow requires two signatures:

  1. Permit2 PermitWitnessTransferFrom — authorizes the X402 proxy to transfer USDC via Permit2
  2. EIP-2612 Permit — approves the canonical Permit2 contract to spend your USDC (gas sponsoring: the backend submits the tx, so the payer pays no gas)

Constants (same on all EVM chains):

const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const X402_EXACT_PERMIT2_PROXY = '0x402085c248EeA27D92E8b30b2C58ed07f9E20001';

TypeScript Example (ethers):

import { Wallet, Contract } from 'ethers';
import * as crypto from 'crypto';

function randomNonce256Hex(): string {
  return '0x' + crypto.randomBytes(32).toString('hex');
}

function randomNonce256Decimal(): string {
  const bytes = crypto.randomBytes(32);
  let value = 0n;
  for (const b of bytes) {
    value = (value << 8n) | BigInt(b);
  }
  return value.toString();
}

async function buildPermit2SettleProof(
  paymentRequirements: PaymentRequirements,
  payerAddress: string,
  privateKey: string,
  rpcUrl: string
): Promise<string> {
  // Parse chainId from CAIP-2 (eip155:42161 -> 42161)
  const network = paymentRequirements.network;
  const chainIdMatch = network.match(/eip155:(\d+)/);
  if (!chainIdMatch) {
    throw new Error(`Invalid network format: ${network}`);
  }
  const chainId = parseInt(chainIdMatch[1], 10);

  const amount = paymentRequirements.amount;
  const payTo = paymentRequirements.payTo;
  const asset = paymentRequirements.asset;
  const extra = paymentRequirements.extra || {};
  const maxTimeout = paymentRequirements.maxTimeoutSeconds || 600;

  const now = Math.floor(Date.now() / 1000);
  const validAfter = (now - 600).toString();
  const deadline = (now + maxTimeout).toString();

  const wallet = new Wallet(privateKey);

  // --- Signature 1: Permit2 PermitWitnessTransferFrom ---
  const permit2Nonce = randomNonce256Decimal();
  const permit2Domain = {
    name: 'Permit2',
    chainId: chainId,
    verifyingContract: PERMIT2_ADDRESS,
  };
  const permit2Types = {
    PermitWitnessTransferFrom: [
      { name: 'permitted', type: 'TokenPermissions' },
      { name: 'spender', type: 'address' },
      { name: 'nonce', type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
      { name: 'witness', type: 'Witness' },
    ],
    TokenPermissions: [
      { name: 'token', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    Witness: [
      { name: 'to', type: 'address' },
      { name: 'validAfter', type: 'uint256' },
    ],
  };
  const permit2Message = {
    permitted: { token: asset, amount },
    spender: X402_EXACT_PERMIT2_PROXY,
    nonce: permit2Nonce,
    deadline,
    witness: { to: payTo, validAfter },
  };

  const permit2Signature = await wallet.signTypedData(
    permit2Domain,
    permit2Types,
    permit2Message
  );

  // --- Signature 2: EIP-2612 Permit (approve Permit2 to spend tokens) ---
  // Read current nonce from USDC contract
  const { JsonRpcProvider } = await import('ethers');
  const provider = new JsonRpcProvider(rpcUrl);
  const usdcContract = new Contract(
    asset,
    ['function nonces(address owner) view returns (uint256)'],
    provider
  );
  const eip2612Nonce = await usdcContract.nonces(payerAddress);

  const permitDomain = {
    name: extra.name || 'USD Coin',
    version: extra.version || '2',
    chainId,
    verifyingContract: asset,
  };
  const permitTypes = {
    Permit: [
      { name: 'owner', type: 'address' },
      { name: 'spender', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'nonce', type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
    ],
  };
  const permitMessage = {
    owner: payerAddress,
    spender: PERMIT2_ADDRESS,
    value: amount,
    nonce: eip2612Nonce.toString(),
    deadline,
  };

  const permitSignature = await wallet.signTypedData(
    permitDomain,
    permitTypes,
    permitMessage
  );

  // --- Build X402 v2 payload with extensions ---
  const payload = {
    x402Version: 2,
    resource: {
      url: paymentRequirements.resource || '/api/intents',
      description: paymentRequirements.description || 'X402 payment',
      mimeType: 'application/json',
    },
    accepted: {
      scheme: paymentRequirements.scheme || 'exact',
      network,
      amount,
      asset,
      payTo,
      maxTimeoutSeconds: maxTimeout,
      extra: paymentRequirements.extra || {},
    },
    payload: {
      signature: permit2Signature,
      permit2Authorization: {
        permitted: { token: asset, amount },
        from: payerAddress,
        spender: X402_EXACT_PERMIT2_PROXY,
        nonce: permit2Nonce,
        deadline,
        witness: { to: payTo, validAfter },
      },
    },
    extensions: {
      eip2612GasSponsoring: {
        info: {
          from: payerAddress,
          asset,
          spender: PERMIT2_ADDRESS,
          amount,
          nonce: eip2612Nonce.toString(),
          deadline,
          signature: permitSignature,
          version: '1',
        },
      },
    },
  };

  return Buffer.from(JSON.stringify(payload)).toString('base64');
}

Key differences from EIP-3009 (Section 2a):

  EIP-3009 (Base) Permit2 (Non-Base EVM)
Signatures required 1 2 (Permit2 + EIP-2612)
Payload field payload.signature + payload.authorization payload.signature + payload.permit2Authorization + extensions.eip2612GasSponsoring
Gas Payer pays gas Gas sponsored (backend submits tx)
On-chain read None nonces(address) on USDC contract
Nonce format bytes32 (hex) uint256 (decimal) for Permit2, uint256 from contract for EIP-2612

2c. Solana — Three Instructions + VersionedTransaction v0

Partial Sign Required: In Solana X402 payments, the feePayer is the server's gas-sponsoring address — you do not have its private key. You must use partial signing (sign only with your own keypair) and leave the feePayer's signature slot as zero bytes. The server will add the feePayer's signature after receiving your transaction. Do NOT call transaction.sign() — use transaction.addSignature() instead (TypeScript) or manually place your signature at the correct index (Go).

TypeScript Example (@solana/web3.js):

import {
  Keypair,
  PublicKey,
  TransactionMessage,
  VersionedTransaction,
  SystemProgram,
} from '@solana/web3.js';
import {
  createSetComputeUnitLimitInstruction,
  createSetComputeUnitPriceInstruction,
  createTransferCheckedInstruction,
  getAssociatedTokenAddress,
} from '@solana/spl-token';
import nacl from 'tweetnacl';

interface SolanaPaymentRequirements {
  scheme: string;
  network: string;
  amount: string;
  asset: string;
  payTo: string;
  maxTimeoutSeconds: number;
  extra?: {
    feePayer?: string;
    decimals?: number;
  };
}

async function buildSolanasettleProof(
  paymentRequirements: SolanaPaymentRequirements,
  payerKeypair: Keypair
): Promise<string> {
  const payTo = new PublicKey(paymentRequirements.payTo);
  const asset = new PublicKey(paymentRequirements.asset);
  const amount = BigInt(paymentRequirements.amount);
  const extra = paymentRequirements.extra || {};
  const decimals = extra.decimals || 6;

  // IMPORTANT: feePayer is the server's gas-sponsoring address.
  // The user does NOT have the feePayer's private key.
  // The server will add the feePayer signature after receiving this transaction.
  const feePayer = extra.feePayer ? new PublicKey(extra.feePayer) : payTo;

  // Get associated token address
  const sourceATA = await getAssociatedTokenAddress(asset, payerKeypair.publicKey);
  const destATA = await getAssociatedTokenAddress(asset, payTo);

  // Build instructions
  const instructions = [
    // 1. SetComputeUnitLimit
    createSetComputeUnitLimitInstruction({ units: 200000 }),
    // 2. SetComputeUnitPrice
    createSetComputeUnitPriceInstruction({ microLamports: 1 }),
    // 3. TransferChecked
    createTransferCheckedInstruction(
      sourceATA,
      asset,
      destATA,
      payerKeypair.publicKey,
      amount,
      decimals
    ),
  ];

  // Build transaction message
  const messageV0 = new TransactionMessage({
    payerKey: feePayer,
    recentBlockhash: '11111111111111111111111111111111', // Will be replaced by backend
    instructions,
  }).compileToV0Message();

  // Create versioned transaction
  const transaction = new VersionedTransaction(messageV0);

  // CRITICAL: Use partial sign — only sign with YOUR keypair.
  // Do NOT use transaction.sign([payerKeypair]) — that would fail because
  // sign() expects ALL signers (including feePayer) to be provided.
  // The feePayer is the server's address; we don't have its private key.
  // addSignature() lets us sign only our part, leaving the feePayer's
  // signature slot as zero bytes for the server to fill in later.
  transaction.addSignature(payerKeypair.publicKey,
    nacl.sign.detached(transaction.message.serialize(), payerKeypair.secretKey)
  );

  // Serialize and encode
  const transactionBytes = transaction.serialize();
  const transactionBase64 = Buffer.from(transactionBytes).toString('base64');

  // Build X402 v2 payload
  const payload = {
    x402Version: 2,
    resource: {
      url: paymentRequirements.resource || '/api/intents',
      description: paymentRequirements.description || 'X402 payment',
      mimeType: 'application/json',
    },
    accepted: {
      scheme: paymentRequirements.scheme || 'exact',
      network: paymentRequirements.network,
      amount: paymentRequirements.amount,
      asset: paymentRequirements.asset,
      payTo: paymentRequirements.payTo,
      maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds,
      extra: paymentRequirements.extra || {},
    },
    payload: {
      transaction: transactionBase64,
    },
  };

  // Encode as base64
  return Buffer.from(JSON.stringify(payload)).toString('base64');
}

Go Example (github.com/gagliardetto/solana-go):

package main

import (
	"crypto/ed25519"
	"encoding/base64"
	"encoding/json"

	"github.com/gagliardetto/solana-go"
)

// buildSolanaSettleProof constructs a Solana X402 settle proof using partial signing.
//
// CRITICAL CONCEPTS:
//   - feePayer is the SERVER's gas-sponsoring address. You do NOT have its private key.
//   - You must use partial signing: sign only with your own keypair.
//   - The Signatures slice must be pre-allocated with the correct length
//     (one slot per signer in the compiled message). NewTransaction() does NOT
//     do this automatically — you must allocate it yourself.
//   - The feePayer is always index 0 in the Signatures slice. Leave it as
//     zero bytes (64 bytes of 0x00). The server will fill it in.
//   - Your signature goes at the index matching your public key's position
//     in the message's account keys.
func buildSolanaSettleProof(
	paymentRequirements map[string]interface{},
	payerPrivateKey ed25519.PrivateKey,
) (string, error) {
	extra, _ := paymentRequirements["extra"].(map[string]interface{})
	feePayerStr, _ := extra["feePayer"].(string)
	feePayer := solana.MustPublicKeyFromBase58(feePayerStr)
	payTo := solana.MustPublicKeyFromBase58(paymentRequirements["payTo"].(string))
	asset := solana.MustPublicKeyFromBase58(paymentRequirements["asset"].(string))
	_ = payTo
	_ = asset

	// ... (build instructions: SetComputeUnitLimit, SetComputeUnitPrice, TransferChecked)
	// ... (compile to MessageV0 with payerKey = feePayer)

	// After compiling the message to messageBytes:
	var messageBytes []byte // = compiled message bytes

	// Pre-allocate the Signatures slice.
	// The number of required signatures is in messageBytes[0] (the first byte
	// of a Solana v0 message header). Each signature is 64 bytes of zeros initially.
	numSigners := int(messageBytes[0])
	signatures := make([]solana.Signature, numSigners)
	// All slots are initialized to zero bytes — the feePayer slot (index 0)
	// stays as zeros for the server to fill in later.

	// Find YOUR public key's index in the account keys and sign at that index.
	payerPubkey := solana.PublicKeyFromBytes(payerPrivateKey.Public().(ed25519.PublicKey))
	// The feePayer is index 0. Your key will typically be index 1 if you are
	// the second signer. Loop through the message's account keys to find it.
	myIndex := 1 // Typically 1 when feePayer is index 0 and you are the only other signer

	sig := ed25519.Sign(payerPrivateKey, messageBytes)
	copy(signatures[myIndex][:], sig)

	// Serialize: [compact-array of signatures] + [message bytes]
	// Then base64-encode the whole thing for the X402 payload.
	_ = feePayer
	_ = signatures

	// Build X402 v2 payload (same structure as TypeScript example)
	payload := map[string]interface{}{
		"x402Version": 2,
		"payload": map[string]interface{}{
			"transaction": base64.StdEncoding.EncodeToString(messageBytes), // replace with full serialized tx
		},
		// ... include resource, accepted fields
	}

	payloadJSON, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}
	return base64.StdEncoding.EncodeToString(payloadJSON), nil
}

Common Pitfalls (Solana Partial Sign):

Pitfall Symptom Fix
Using transaction.sign() instead of addSignature() (TS) Error: missing private key for feePayer Use transaction.addSignature() with nacl.sign.detached()
Not pre-allocating Signatures slice (Go) Index out of range panic or missing signatures Allocate make([]Signature, numSigners) before placing your signature
Signing at wrong index Server rejects: signature verification failed feePayer is always index 0; your key is at its position in the account keys list
Using Sign() in Go SDK Requires all signer private keys including feePayer Manually construct the signatures slice and place only your signature

Step 3: Complete Payment Flow with SDK

TypeScript/JavaScript Complete Example

import { PublicPayClient } from '@cross402/usdc';
import { Wallet } from 'ethers';
import { buildEVMsettleProof } from './x402-signing';
import { buildPermit2SettleProof } from './x402-permit2-signing';

async function completeX402PaymentFlow(
  recipient: string,
  amount: string,
  payerChain: string
) {
  // Initialize SDK client (public mode, no auth required)
  const client = new PublicPayClient({
    baseUrl: 'https://api-pay.agent.tech',
  });

  // Step 1: Generate or load wallet
  const wallet = Wallet.createRandom();
  const payerAddress = wallet.address;
  const privateKey = wallet.privateKey;

  console.log(`Using payer wallet: ${payerAddress}`);

  // Step 2: Create intent
  const intent = await client.createIntent({
    recipient,
    amount,
    payerChain,
  });

  console.log(`Intent created: ${intent.intentId}`);
  console.log(`Status: ${intent.status}`);
  console.log(`Expires at: ${intent.expiresAt}`);

  // Step 3: Sign X402 proof locally (choose method based on payment_requirements)
  let settleProof: string;
  const pr = intent.paymentRequirements;

  if (pr.extra?.assetTransferMethod === 'permit2') {
    // Non-Base EVM chains (Arbitrum, Polygon, Ethereum, Monad, HyperEVM)
    settleProof = await buildPermit2SettleProof(pr, payerAddress, privateKey, rpcUrl);
  } else {
    // Base (EIP-3009 TransferWithAuthorization)
    settleProof = buildEVMsettleProof(pr, payerAddress, privateKey);
  }

  console.log('X402 proof signed locally');

  // Step 4: Submit proof
  const submitResult = await client.submitProof(intent.intentId, settleProof);
  console.log(`Proof submitted. Status: ${submitResult.status}`);

  // Step 5: Poll until completion
  let currentIntent = submitResult;
  const maxAttempts = 60; // 10 minutes max (60 * 10 seconds)
  let attempts = 0;

  while (
    currentIntent.status !== 'BASE_SETTLED' &&
    currentIntent.status !== 'PARTIAL_SETTLEMENT' &&
    currentIntent.status !== 'EXPIRED' &&
    currentIntent.status !== 'VERIFICATION_FAILED' &&
    attempts < maxAttempts
  ) {
    await new Promise(resolve => setTimeout(resolve, 10000)); // Poll every 10 seconds
    attempts++;

    try {
      currentIntent = await client.getIntent(intent.intentId);
      console.log(`[${attempts}] Status: ${currentIntent.status}`);
    } catch (error) {
      console.error(`Error polling status: ${error}`);
      // Continue polling on transient errors
    }
  }

  // Final status check
  if (currentIntent.status === 'BASE_SETTLED') {
    console.log('✅ Payment complete!');
    console.log(`Transaction hash: ${currentIntent.basePayment?.txHash}`);
    return {
      success: true,
      intentId: currentIntent.intentId,
      status: currentIntent.status,
      txHash: currentIntent.basePayment?.txHash,
    };
  } else {
    console.error(`❌ Payment failed: ${currentIntent.status}`);
    return {
      success: false,
      intentId: currentIntent.intentId,
      status: currentIntent.status,
    };
  }
}

// Usage
completeX402PaymentFlow(
  '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
  '10.50',
  'base'
).then(result => {
  console.log('Final result:', result);
});

Go Complete Example

package main

import (
    "context"
    "fmt"
    "log"
    "time"
    "github.com/cross402/usdc-sdk-go"
    "github.com/ethereum/go-ethereum/crypto"
)

func completeX402PaymentFlow(
    ctx context.Context,
    recipient string,
    amount string,
    payerChain string,
) error {
    // Initialize SDK client (public mode, no auth required)
    client, err := pay.NewClient("https://api-pay.agent.tech")
    if err != nil {
        return err
    }

    // Step 1: Generate wallet
    privateKey, err := crypto.GenerateKey()
    if err != nil {
        return err
    }
    payerAddress := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
    privateKeyHex := fmt.Sprintf("%x", crypto.FromECDSA(privateKey))

    log.Printf("Using payer wallet: %s", payerAddress)

    // Step 2: Create intent
    resp, err := client.CreateIntent(ctx, &pay.CreateIntentRequest{
        Recipient:  recipient,
        Amount:     amount,
        PayerChain: payerChain,
    })
    if err != nil {
        return err
    }

    log.Printf("Intent created: %s", resp.IntentID)
    log.Printf("Status: %s", resp.Status)

    // Step 3: Sign X402 proof locally
    settleProof, err := buildEVMsettleProof(
        resp.PaymentRequirements,
        payerAddress,
        privateKeyHex,
    )
    if err != nil {
        return err
    }

    log.Println("X402 proof signed locally")

    // Step 4: Submit proof
    proofResult, err := client.SubmitProof(ctx, resp.IntentID, settleProof)
    if err != nil {
        return err
    }

    log.Printf("Proof submitted. Status: %s", proofResult.Status)

    // Step 5: Poll until completion
    maxAttempts := 60
    attempts := 0
    currentIntent := proofResult

    for attempts < maxAttempts {
        if currentIntent.Status == pay.StatusBaseSettled ||
           currentIntent.Status == pay.StatusPartialSettlement ||
           currentIntent.Status == pay.StatusExpired ||
           currentIntent.Status == pay.StatusVerificationFailed {
            break
        }

        time.Sleep(10 * time.Second)
        attempts++

        intent, err := client.GetIntent(ctx, resp.IntentID)
        if err != nil {
            log.Printf("Error polling status: %v", err)
            continue
        }

        currentIntent = intent
        log.Printf("[%d] Status: %s", attempts, currentIntent.Status)
    }

    // Final status check
    if currentIntent.Status == pay.StatusBaseSettled {
        log.Println("✅ Payment complete!")
        log.Printf("Transaction hash: %s", currentIntent.BasePayment.TxHash)
        return nil
    } else {
        return fmt.Errorf("payment failed: %s", currentIntent.Status)
    }
}

Payment Flow and Status

After you submit settle_proof, the backend verifies it and settles on the source chain via the X402 facilitator, then executes the Base payment. Poll getIntent(intentId) until a terminal status.

Status values:

Status Description
AWAITING_PAYMENT Intent created; no proof yet
PENDING Proof submitted; verification/settlement in progress
SOURCE_SETTLED Source chain settled
BASE_SETTLING Base payment in progress
BASE_SETTLED Done; merchant received USDC on Base
PARTIAL_SETTLEMENT Partial amount settled; remainder not fulfilled
VERIFICATION_FAILED Proof invalid or settlement failed
EXPIRED Intent timed out (e.g. 10 min)

Valid payer_chain: See Supported Chains for the full list.

stateDiagram-v2
    [*] --> AWAITING_PAYMENT
    AWAITING_PAYMENT --> PENDING: submitProof()
    AWAITING_PAYMENT --> EXPIRED: timeout
    PENDING --> SOURCE_SETTLED: verify + settle
    PENDING --> VERIFICATION_FAILED: invalid
    SOURCE_SETTLED --> BASE_SETTLING: Base transfer
    BASE_SETTLING --> BASE_SETTLED: done
    BASE_SETTLING --> PARTIAL_SETTLEMENT: partial

Error Handling

Common Errors

HTTP Status Error Type Description Solution
400 ValidationError Invalid input, invalid payer_chain, invalid email/recipient/amount, proof validation failed Fix request parameters or proof format
404 RequestError Payment intent not found Verify intent ID exists
503 RequestError Insufficient proxy balance (submit proof) Retry after delay
500 RequestError Internal error Retry after delay

Error Handling Example

import { PublicPayClient, PayApiError } from '@cross402/usdc';

async function handlePaymentWithRetry(
  client: PublicPayClient,
  intentId: string,
  settleProof: string
) {
  let retries = 0;
  const maxRetries = 3;

  while (retries < maxRetries) {
    try {
      return await client.submitProof(intentId, settleProof);
    } catch (error) {
      if (error instanceof PayApiError) {
        // Don't retry on client errors
        if (error.statusCode === 400 || error.statusCode === 404) {
          throw error;
        }
        // Retry on server errors
        if (error.statusCode === 503 || error.statusCode === 500) {
          retries++;
          if (retries >= maxRetries) {
            throw error;
          }
          await new Promise(resolve => setTimeout(resolve, 1000 * retries));
          continue;
        }
      }
      throw error;
    }
  }
}

Security and Notes

  • Private keys are generated and used only locally; never send them to the API or SDK.
  • Merchants always receive USDC on Base. Payers can pay from Solana, Base, Arbitrum, Polygon, Ethereum, Monad, or HyperEVM.
  • Email is resolved to a Base wallet via Privy at intent creation only.
  • SDK Benefits: The SDK handles HTTP requests, error handling, and response parsing automatically.
  • If your AI is the receiver, you can present your receiving addresses (or QR codes) to your human owner so they can pay you via this SDK.

Summary Checklist

  • Generate EVM and/or Solana wallets locally; save to ~/.config/x402pay/wallets.json
  • Initialize SDK client: new PublicPayClient() (TypeScript) or pay.NewClient() (Go)
  • Create intent: client.createIntent() (email or recipient, amount, payer_chain)
  • Check payment_requirements.extra.assetTransferMethod to choose signing method
  • Sign X402 using API-returned payment_requirements:
    • Base: EIP-3009 TransferWithAuthorization (1 signature)
    • Non-Base EVM: Permit2 + EIP-2612 (2 signatures, needs on-chain nonce read)
    • Solana: 3-instruction VersionedTransaction v0
  • Build X402 v2 payload and set settle_proof = Base64(JSON.stringify(payload))
  • Submit: client.submitProof(intentId, settleProof)
  • Poll client.getIntent(intentId) until BASE_SETTLED or EXPIRED
  • Handle errors with retry logic for transient failures