Developer Quickstart
This guide will walk you through integrating ArPay into your application, from setting up your development environment to processing your first transaction.
# Prerequisites
Before you begin, ensure you have:
- Node.js ≥ 16.x installed
- Rust 1.75+ and Anchor 0.29+ (for smart contract development)
- Python 3.11+ (for oracle bridge)
- A Solana wallet (Phantom, Solflare, etc.)
- SOL tokens for transaction fees
- USDC (SPL) on Solana devnet/mainnet
# Quick Overview
# What we'll build
┌─────────────┐
│ Your App │ ← Integrates ArPay payment flow
└──────┬──────┘
│
▼
┌─────────────┐
│ ArPay SDK │ ← Handles blockchain interaction
└──────┬──────┘
│
▼
┌─────────────┐
│ Solana │ ← Executes smart contract
└─────────────┘
# Option 1: Frontend Integration (No Backend Required)
Perfect for static sites, PWAs, or client-side apps.
Step 1: Install Dependencies
npm install @solana/web3.js @solana/wallet-adapter-react @solana/wallet-adapter-wallets
npm install @coral-xyz/anchor @project-serum/anchor
npm install @pythnetwork/client
Step 2: Set Up Wallet Provider
// app/providers/WalletProvider.tsx
'use client';
import { FC, useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';
require('@solana/wallet-adapter-react-ui/styles.css');
export const WalletContextProvider: FC<{ children: React.ReactNode }> = ({ children }) => {
const network = WalletAdapterNetwork.Mainnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter({ network }),
],
[network]
);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
};
Step 3: Create Payment Component
// components/ArPayButton.tsx
'use client';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { PublicKey, Transaction } from '@solana/web3.js';
import { Program, AnchorProvider } from '@coral-xyz/anchor';
import { useState } from 'react';
import { ArPayIDL } from '@/idl/arpay';
const ARPAY_PROGRAM_ID = new PublicKey('ArPayXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
const USDC_MINT = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
interface ArPayButtonProps {
merchantId: string; // QRIS NMID
amountIDR: number; // Amount in IDR (e.g., 100000 for Rp 100,000)
onSuccess?: (signature: string) => void;
onError?: (error: Error) => void;
}
export function ArPayButton({ merchantId, amountIDR, onSuccess, onError }: ArPayButtonProps) {
const { connection } = useConnection();
const wallet = useWallet();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<string>('');
const handlePayment = async () => {
if (!wallet.publicKey || !wallet.signTransaction) {
alert('Please connect your wallet first!');
return;
}
setLoading(true);
setStatus('Fetching exchange rate...');
try {
// 1. Get USDC/IDR rate
const rate = await fetchExchangeRate();
const usdcAmount = Math.ceil((amountIDR / rate) * 1_000_000); // 6 decimals
setStatus(`Rate: 1 USDC = ${rate.toFixed(2)} IDR`);
// 2. Build and send transaction
const provider = new AnchorProvider(connection, wallet as any, {});
const program = new Program(ArPayIDL, ARPAY_PROGRAM_ID, provider);
setStatus('Preparing transaction...');
const nonce = Date.now(); // Use timestamp as nonce
const tx = await program.methods
.initiateSettlement(
merchantId,
amountIDR * 100, // Convert to cents
usdcAmount,
nonce
)
.accounts({
payer: wallet.publicKey,
// ... other accounts (derived programmatically)
})
.rpc();
setStatus('Transaction confirmed!');
console.log('Transaction signature:', tx);
onSuccess?.(tx);
// Poll for settlement completion
await pollSettlementStatus(tx);
} catch (error) {
console.error('Payment failed:', error);
setStatus('Payment failed');
onError?.(error as Error);
} finally {
setLoading(false);
}
};
return (
<button
onClick={handlePayment}
disabled={loading || !wallet.connected}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg disabled:opacity-50"
>
{loading ? (
<span className="flex items-center gap-2">
<Spinner />
{status}
</span>
) : (
`Pay Rp ${amountIDR.toLocaleString('id-ID')}`
)}
</button>
);
}
// Helper: Fetch USDC/IDR exchange rate
async function fetchExchangeRate(): Promise<number> {
// In production, use Pyth Network on-chain oracle
const pythPrice = await fetch('https://xc-mainnet.pyth.network/api/latest_price_feeds?ids[]=0x...')
.then(r => r.json());
const usdcUsd = pythPrice[0].price.price / (10 ** pythPrice[0].price.expo);
// Fetch USD/IDR from off-chain API
const usdIdr = await fetch('https://api.exchangerate-api.com/v4/latest/USD')
.then(r => r.json())
.then(data => data.rates.IDR);
return usdcUsd * usdIdr;
}
// Helper: Poll for settlement completion
async function pollSettlementStatus(signature: string, maxAttempts = 20): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
// Check if escrow has been released (settlement complete)
// In production, listen to WebSocket events or query PDA status
console.log(`Polling attempt ${i + 1}/${maxAttempts}`);
}
}
Step 4: Use in Your App
// app/page.tsx
import { ArPayButton } from '@/components/ArPayButton';
import { WalletContextProvider } from '@/providers/WalletProvider';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
export default function Home() {
return (
<WalletContextProvider>
<main className="min-h-screen p-8">
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold mb-8">ArPay Demo</h1>
<WalletMultiButton className="mb-4" />
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Pay Merchant</h2>
<ArPayButton
merchantId="ID1234567890123" // QRIS NMID
amountIDR={100000} // Rp 100,000
onSuccess={(sig) => {
console.log('Payment successful!', sig);
alert('Payment sent! Merchant will receive IDR in ~5 seconds.');
}}
onError={(err) => {
console.error('Payment failed:', err);
alert('Payment failed. Please try again.');
}}
/>
</div>
</div>
</main>
</WalletContextProvider>
);
}
Step 5: Test on Devnet
# 1. Get devnet SOL
solana airdrop 2 <YOUR_WALLET_ADDRESS> --url devnet
# 2. Get devnet USDC
# Visit https://spl-token-faucet.com/ and request USDC
# 3. Run your app
npm run dev
# 4. Navigate to http://localhost:3000 and test!
# Option 2: Full-Stack Integration (With Backend)
For applications that need server-side verification, webhooks, or custom business logic.
Backend Setup (Node.js/Express)
// server/index.ts
import express from 'express';
import { Connection, PublicKey } from '@solana/web3.js';
import { Program, AnchorProvider } from '@coral-xyz/anchor';
const app = express();
app.use(express.json());
const connection = new Connection('https://api.mainnet-beta.solana.com');
const ARPAY_PROGRAM_ID = new PublicKey('ArPayXXX...');
// Endpoint: Create payment intent
app.post('/api/create-payment', async (req, res) => {
const { merchantId, amountIDR } = req.body;
// 1. Fetch current USDC/IDR rate
const rate = await fetchExchangeRate();
const usdcAmount = Math.ceil((amountIDR / rate) * 1_000_000);
// 2. Generate unique payment reference
const paymentId = generatePaymentId();
// 3. Store in database
await db.payments.create({
id: paymentId,
merchantId,
amountIDR,
usdcAmount,
rate,
status: 'pending',
createdAt: new Date(),
});
res.json({
paymentId,
usdcAmount,
rate,
expiresAt: Date.now() + 120_000, // 2 minutes
});
});
// Endpoint: Verify payment status
app.get('/api/payment/:id', async (req, res) => {
const payment = await db.payments.findOne({ id: req.params.id });
if (!payment) {
return res.status(404).json({ error: 'Payment not found' });
}
// Query Solana for transaction status
const escrowPda = deriveEscrowPDA(payment.merchantId, payment.nonce);
const escrowAccount = await connection.getAccountInfo(escrowPda);
if (!escrowAccount) {
payment.status = 'settled'; // Escrow released
}
res.json(payment);
});
// Webhook: Receive settlement notifications from oracle
app.post('/webhooks/settlement', async (req, res) => {
const { signature, merchantId, nonce, status } = req.body;
// Verify signature (important!)
const isValid = verifyWebhookSignature(req.headers['x-signature'], req.body);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Update payment status
await db.payments.updateOne(
{ merchantId, nonce },
{ status, settledAt: new Date() }
);
// Trigger business logic (e.g., fulfill order, send email)
await fulfillOrder(merchantId, nonce);
res.json({ received: true });
});
app.listen(3001, () => {
console.log('ArPay backend running on port 3001');
});
# Option 3: QR Code Integration
Enable physical merchants to accept crypto payments via QRIS scanners.
Generate ArPay-Compatible QRIS
// utils/qris.ts
import QRCode from 'qrcode';
interface QRISPayload {
merchantId: string; // National Merchant ID (NMID)
merchantName: string;
amount: number; // IDR amount
transactionId?: string;
}
export async function generateArPayQRIS(payload: QRISPayload): Promise<string> {
// Encode QRIS according to EMVCo Merchant Presented QR spec
const qrisData = encodeEMVCo({
payloadFormatIndicator: '01',
pointOfInitiationMethod: '12', // Dynamic QR
merchantAccountInformation: {
globallyUniqueIdentifier: payload.merchantId,
merchantName: payload.merchantName,
},
transactionCurrency: '360', // IDR currency code
transactionAmount: payload.amount.toString(),
countryCode: 'ID',
merchantCategoryCode: '0000',
additionalData: {
referenceLabel: payload.transactionId || generateTransactionId(),
},
});
// Generate QR code
const qrCodeDataURL = await QRCode.toDataURL(qrisData, {
errorCorrectionLevel: 'M',
width: 300,
});
return qrCodeDataURL;
}
// Encode according to EMVCo spec (simplified)
function encodeEMVCo(data: any): string {
let payload = '';
payload += encodeTLV('00', data.payloadFormatIndicator);
payload += encodeTLV('01', data.pointOfInitiationMethod);
payload += encodeTLV('52', data.merchantCategoryCode);
payload += encodeTLV('53', data.transactionCurrency);
payload += encodeTLV('54', data.transactionAmount);
payload += encodeTLV('58', data.countryCode);
// Merchant Account Information (Tag 26 for ID)
const merchantInfo =
encodeTLV('00', data.merchantAccountInformation.globallyUniqueIdentifier) +
encodeTLV('01', data.merchantAccountInformation.merchantName);
payload += encodeTLV('26', merchantInfo);
// Additional Data (Tag 62)
const additionalData = encodeTLV('01', data.additionalData.referenceLabel);
payload += encodeTLV('62', additionalData);
// CRC (Tag 63) - calculate and append
const crc = calculateCRC(payload + '6304');
payload += encodeTLV('63', crc);
return payload;
}
function encodeTLV(tag: string, value: string): string {
const length = value.length.toString().padStart(2, '0');
return tag + length + value;
}
function calculateCRC(data: string): string {
// ISO/IEC 13239 CRC-16-CCITT
// Implementation omitted for brevity
return '0000'; // Placeholder
}
# Smart Contract Development
Clone and Build
# Clone ArPay repository
git clone https://github.com/arpay/smart-contract
cd smart-contract
# Install dependencies
yarn install
# Build program
anchor build
# Run tests
anchor test
# Deploy to devnet
anchor deploy --provider.cluster devnet
Program Structure
programs/arpay/
├── src/
│ ├── lib.rs # Main program logic
│ ├── instructions/
│ │ ├── initiate_settlement.rs
│ │ ├── release_escrow.rs
│ │ └── mod.rs
│ ├── state/
│ │ ├── escrow.rs # Escrow account schema
│ │ └── mod.rs
│ ├── errors.rs # Custom error codes
│ └── events.rs # Event definitions
├── Cargo.toml
└── Xargo.toml
Writing Tests
// tests/arpay.ts
import * as anchor from '@coral-xyz/anchor';
import { Program } from '@coral-xyz/anchor';
import { ArPay } from '../target/types/ar_pay';
import { expect } from 'chai';
describe('arpay', () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.ArPay as Program<ArPay>;
it('Initiates settlement and locks USDC in escrow', async () => {
const merchantId = 'ID1234567890123';
const idrAmount = 100_000_00; // Rp 100,000 in cents
const usdcAmount = 6_850_000; // 6.85 USDC
const nonce = Date.now();
const tx = await program.methods
.initiateSettlement(merchantId, idrAmount, usdcAmount, nonce)
.accounts({
payer: provider.wallet.publicKey,
// ... derived accounts
})
.rpc();
console.log('Transaction signature:', tx);
// Verify escrow account created
const [escrowPda] = PublicKey.findProgramAddressSync(
[
Buffer.from('escrow'),
provider.wallet.publicKey.toBuffer(),
Buffer.from(merchantId),
new anchor.BN(nonce).toArrayLike(Buffer, 'le', 8),
],
program.programId
);
const escrowAccount = await program.account.escrow.fetch(escrowPda);
expect(escrowAccount.usdcAmount.toNumber()).to.equal(usdcAmount);
});
it('Releases escrow to treasury after disbursement', async () => {
// Test authority-signed release
// ... (implementation omitted)
});
it('Refunds payer after timeout', async () => {
// Test timeout refund mechanism
// ... (implementation omitted)
});
});
# Oracle Bridge Setup
Prerequisites
# Install Python dependencies
pip install solana solders aiohttp python-dotenv websockets
Configuration
# .env
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
SOLANA_WSS_URL=wss://api.mainnet-beta.solana.com
ARPAY_PROGRAM_ID=ArPayXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
AUTHORITY_PRIVATE_KEY=<base58_encoded_keypair>
XENDIT_API_KEY=<your_xendit_key>
DATABASE_URL=postgresql://user:pass@localhost/arpay
Run Oracle
python oracle_bridge/main.py
Docker Deployment
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY oracle_bridge/ .
CMD ["python", "main.py"]
# Build and run
docker build -t arpay-oracle .
docker run -d --env-file .env --name arpay-oracle arpay-oracle
# Testing Checklist
-
Devnet Testing
- Connect wallet to devnet
- Request devnet SOL from faucet
- Request devnet USDC from faucet
- Submit test transaction
- Verify escrow creation on Solscan
- Confirm event emission in logs
-
Integration Testing
- Test rate fetching (Pyth oracle)
- Test slippage protection
- Test timeout refund mechanism
- Test oracle event listening
- Test Xendit API (sandbox mode)
-
Error Handling
- Insufficient USDC balance
- Network disconnection
- Rate slippage exceeded
- Payment gateway failure
- Duplicate transaction (nonce replay)
# Common Issues & Solutions
Issue: "Simulation failed: Insufficient funds"
Solution: Ensure your wallet has enough SOL for transaction fees (~0.00001 SOL) and USDC for the payment amount.
# Check balances
solana balance <YOUR_WALLET>
spl-token balance EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
Issue: "Account already initialized"
Solution: Nonce collision. Ensure each transaction uses a unique nonce (timestamp + random salt recommended).
const nonce = Date.now() * 1000 + Math.floor(Math.random() * 1000);
Issue: "Slippage tolerance exceeded"
Solution: Rate changed between display and execution. Increase tolerance or refresh rate immediately before signing.
const MAX_SLIPPAGE = 0.01; // 1%
# Next Steps
✅ You've completed the quickstart!
Dive deeper:
- Architecture — Understand the tri-layer design
- Smart Contract API — Full instruction reference
- Security Best Practices — Production checklist
- API Reference — Complete SDK documentation
Need help? Join our Discord community or open an issue on GitHub.