SwiftRail is a gasless USDC checkout stack for Arbitrum Sepolia.
Customers sign once with USDC permit and do not pay ETH gas. A relayer sponsors gas, takes a small fee, and settles net USDC to the merchant.
Think of this as 5 parts working together:
frontend: the shop UI where user clicks buy.sdk: helps connect wallet and enforce Arbitrum Sepolia network.relayer: backend service that sends on-chain transactions and pays gas.payment contract: verifies permit and pulls USDC from customer.USDC token: where permit nonce/balance and token transfers happen.
Payment sequence:
- User clicks buy in frontend.
- Frontend asks relayer for fee quote (
POST /quote). - Frontend calls
sdk.checkout(...). - SDK prompts user to sign USDC permit typed-data in wallet.
- SDK submits permit payload to relayer (
POST /submit-payment). - Relayer calls contract
processPayment(...)(tx #1). - Contract does
permit+transferFrom(user -> fee_collector). - Relayer waits for tx #1 confirmation.
- Relayer transfers net USDC to merchant (tx #2).
- Relayer waits for tx #2 confirmation.
- Frontend refreshes dashboard (
GET /dashboard/:merchant).
This is exactly why the logs show:
Payment transaction submittedPayment transaction confirmedMerchant settlement tx submittedMerchant settlement tx confirmed
- Stylus payment contract (
contracts/payment-contract) for permit + transfer flow. - Rust relayer backend (
relayer) that submits transactions, computes fees, and tracks merchant settlements. - WASM SDK (
sdk) for wallet connect, network guard, and checkout primitives. - Demo storefront + checkout modal + merchant dashboard (
frontend).
- Intelligent fee model with quote endpoint (
POST /quote) and fee explanation. - Fiat settlement dashboard APIs (
GET /dashboard/:merchant) and simulated cashout (POST /cashout). - Persistent relayer state to disk (
STATE_FILE_PATH, defaultrelayer/state.json) so dashboard data survives relayer restarts. - Relayer health endpoint (
GET /health) for faster troubleshooting. - Startup safety checks for chain ID (
EXPECTED_CHAIN_ID, default421614) and.envplaceholder detection. - Payment input validation in relayer (non-zero addresses, positive amount, non-expired deadline, valid
v). - SDK network guard: wallet connect now enforces/switches to Arbitrum Sepolia.
- Dev UX scripts:
scripts/deploy_contract.sh,scripts/start_relayer.sh,scripts/run_frontend.sh.
contracts/payment-contract/src/lib.rs: Stylus contract.relayer/src/main.rs: relayer API + transaction sender + fee model + state persistence.sdk/src/lib.rs: WASM SDK.frontend/index.html: demo storefront and dashboard UI.frontend/app.js: checkout and dashboard logic.scripts/deploy_contract.sh: build, verify, deploy, and initialize contract.scripts/start_relayer.sh: relayer launcher.scripts/run_frontend.sh: local frontend server.
- Rust (stable) and Cargo.
wasm32-unknown-unknownRust target.cargo-stylusfor Stylus build/deploy.python3to serve frontend.- Optional but recommended: Foundry
castfor contract initialize verification. - MetaMask connected to Arbitrum Sepolia.
- Arbitrum Sepolia ETH for relayer wallet gas.
- Arbitrum Sepolia USDC for customer wallet.
- Arbitrum Sepolia USDC for relayer settlement sender when fee-collector/sender config differs.
cd /home/peter/gasless
rustup target add wasm32-unknown-unknown
cargo install cargo-stylusGenerate relayer wallet keystore:
cargo run --manifest-path relayer/Cargo.toml --bin generate_walletThis creates secrets/keystore.json. Fund that relayer address with Arbitrum Sepolia ETH.
chmod +x scripts/deploy_contract.sh
./scripts/deploy_contract.shThe script will:
- Build and check the Stylus contract.
- Prompt for deployer private key.
- Prompt for USDC address.
- Deploy contract to Arbitrum Sepolia.
- If
castis installed, callinitialize(address)automatically.
If cast is missing, initialize manually:
cast send <DEPLOYED_CONTRACT_ADDRESS> "initialize(address)" <USDC_ADDRESS> \
--private-key <DEPLOYER_PRIVATE_KEY> \
--rpc-url https://sepolia-rollup.arbitrum.io/rpcCreate .env from template:
cp .env.example .envFill values in .env:
RPC_URL: Arbitrum Sepolia RPC.EXPECTED_CHAIN_ID:421614.CONTRACT_ADDRESS: deployed Stylus contract.USDC_ADDRESS: same USDC token used at deploy/initialize.KEYSTORE_PATH: usuallysecrets/keystore.json.RELAYER_FEE_COLLECTORoptional.STATE_FILE_PATHoptional.
./scripts/start_relayer.shYou will be prompted for keystore password.
Health check:
curl -s http://127.0.0.1:3000/healthQuote check:
curl -s -X POST http://127.0.0.1:3000/quote \
-H "content-type: application/json" \
-d '{"amount":"20000000"}'wasm-pack build sdk --target web --out-dir ../frontend/pkgIf you did not modify sdk/src/lib.rs, you can skip this and use the committed frontend/pkg.
./scripts/run_frontend.shOpen http://127.0.0.1:4173.
Set Runtime Config in UI:
- Relayer URL:
http://127.0.0.1:3000 - Payment Contract: deployed contract address.
- USDC Address: token address.
- Merchant Address: wallet that should receive net settlements.
- Bank Account Hint: any text (used for simulated cashout log).
Click Save Config.
- Deploy/initialize contract.
- Fill
.env. - Start relayer.
- Build SDK wasm if needed.
- Serve frontend.
- Connect wallet and run payment flow.
For hackathon demos, use:
./scripts/dev_up.shWhat it does:
- Validates
.envis present and has no<...>placeholders. - Starts frontend server in background (
http://127.0.0.1:4173). - Starts relayer in foreground (you enter keystore password here).
- Cleans up frontend automatically when you stop the script.
- Customer clicks
Pay With Arbitrum. - Frontend requests fee preview from relayer
POST /quote. - Checkout modal shows gross amount, relayer fee, and merchant net.
- Frontend invokes SDK checkout (
sdk.checkout(...)). - SDK signs permit typed-data and sends payload to relayer
POST /submit-payment. - Relayer preflights permit call and normalizes
v(0/1to27/28when needed). - Relayer submits contract call
processPayment(...)and waits for receipt. - Contract executes
permitthentransferFromto fee collector. - Relayer submits merchant settlement transfer and waits for receipt.
- Frontend fetches
GET /dashboard/:merchantand updates metrics/history. - Merchant may click
Cash Out To Bankfor simulated off-ramp record (POST /cashout).
GET /health: relayer health, chain/config, record counts.POST /quote: returns fee quote for USDC amount.POST /submit-payment: executes gasless payment path.GET /dashboard/:merchant: merchant metrics and recent records.POST /cashout: simulated cashout of available merchant balance.
merchant address: receives net settlement and is used for dashboard aggregation.deployer address: contract owner/admin, used for deployment and initialization.relayer wallet: signs on-chain relayer transactions and pays gas.relayer fee collector: receives customer gross funds before merchant net settlement.USDC_ADDRESS: permit and settlement token contract.CONTRACT_ADDRESS: gasless payment contract called by relayer.
For hackathon MVP, merchant and deployer can be the same address.
- ETH is always needed by relayer wallet for gas.
- USDC movement is two-step: user funds first arrive at
fee_collector, then relayer sends net to merchant. - If
RELAYER_FEE_COLLECTORis unset, relayer uses unlocked wallet as fee collector (default happy path). - If
RELAYER_FEE_COLLECTORpoints to a different address, the relayer sender may have no USDC and settlement can fail. - Error
ERC20: transfer amount exceeds balancemeans the settlement sender did not have enough USDC at that moment.
Use the SDK when you want your app to handle wallet connect, chain guard, permit signing, and relayer submission through one interface.
Published npm package: @0takuc0mrade/swiftrail-sdk
Package URL: https://www.npmjs.com/package/@0takuc0mrade/swiftrail-sdk
Framework-specific examples (React/Vite/Next.js): docs/sdk-integration.md
Publishing to npm/crates/JSR: docs/sdk-publishing.md
Install:
npm i @0takuc0mrade/swiftrail-sdkwasm-pack build sdk --target web --out-dir ./frontend/pkgIf your app lives elsewhere, point --out-dir to your app package folder and import from there.
import init, { ArbitrumCheckout } from "@0takuc0mrade/swiftrail-sdk";
await init();
const sdk = new ArbitrumCheckout(
"http://127.0.0.1:3000", // relayer URL
"0xYourPaymentContract", // CONTRACT_ADDRESS
"0xYourUsdcAddress" // USDC_ADDRESS
);const userAddress = await sdk.connect_wallet();What this does:
- Requests account access from MetaMask.
- Verifies current chain is Arbitrum Sepolia (
421614). - Attempts
wallet_switchEthereumChainif user is on the wrong network.
const merchantAddress = "0xMerchantAddress";
const amount = "20000000"; // 20.000000 USDC (6 decimals)
const resultJson = await sdk.checkout(userAddress, merchantAddress, amount);
const result = JSON.parse(resultJson);What checkout(...) does internally:
- Reads USDC permit nonce from chain.
- Reads permit domain name/version from token (
name()/version(), with fallback). - Builds EIP-712 permit typed data.
- Prompts MetaMask signature (
eth_signTypedData_v4). - Sends signed payload to relayer
POST /submit-payment. - Returns relayer response JSON text (or throws on failure).
{
"payment_tx_hash": "0x...",
"settlement_tx_hash": "0x...",
"gross_amount": "20000000",
"fee_amount": "90000",
"net_amount": "19910000",
"fee_bps": 45,
"fee_model_notes": ["..."]
}try {
const resultJson = await sdk.checkout(userAddress, merchantAddress, amount);
const result = JSON.parse(resultJson);
console.log("payment", result.payment_tx_hash);
} catch (err) {
console.error("checkout failed", String(err));
}new ArbitrumCheckout(relayerUrl, contractAddress, usdcAddress)connect_wallet(): Promise<string>checkout(userAddress, recipientAddress, amountBaseUnits): Promise<string>
Notes:
amountBaseUnitsmust be a decimal string in token base units (USDC has 6 decimals).- SDK assumes browser wallet provider (
window.ethereum) is available. - SDK checkout returns JSON text; parse it with
JSON.parse(...).
Wrong chain detectedat relayer startup: Set correctRPC_URLor updateEXPECTED_CHAIN_ID.Payment failed: permit deadline has already expired: Retry checkout and sign again.No available balance to cash out: At least one successful payment is required first.- Wallet connected but payment fails immediately: Confirm customer has USDC and MetaMask is on Arbitrum Sepolia.
- Dashboard data disappeared after restart:
Check
STATE_FILE_PATHand ensure relayer can write to it. Settlement transfer failed ... ERC20: transfer amount exceeds balance: Confirm relayer sender/fee collector alignment and USDC balance on settlement sender. Also ensure relayer process includes receipt-wait sequencing (restart relayer after updates).:4173/favicon.ico 404in browser console: Harmless missing icon in local frontend server; unrelated to checkout logic.USDC nonce call returned empty dataorFailed to parse nonce: VerifyUSDC_ADDRESSis correct for the selected chain and exposesnonces(address).MetaMask provider unavailable: Install/enable MetaMask and run in a browser context (not server-side render).
This repo is optimized for MVP demonstration speed.
In production, next upgrades would be:
- Persistent database instead of JSON file.
- Auth/RBAC for merchant endpoints.
- Rate limiting and abuse protection.
- Retry queue and transaction reconciliation.
- Real fiat off-ramp integration.