Smart Contract API Reference

Complete documentation for the ArPay Solana program deployed on Solana Mainnet-Beta and Devnet.

# Program Information

PropertyValue
Program IDArPayXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
FrameworkAnchor 0.29
LanguageRust 1.75
ClusterMainnet-Beta, Devnet
Repositorygithub.com/arpay/smart-contract

# Program Overview

The ArPay program implements a trustless escrow mechanism for crypto-to-fiat settlements using Program Derived Addresses (PDAs) as atomic fund locks.

Core functionality:

  1. Accept USDC from payers into PDA escrow
  2. Emit events for off-chain oracle processing
  3. Release USDC to treasury upon confirmed fiat disbursement
  4. Enable automatic refunds after timeout

# Instructions

01initializeDeploy once
02initiate_settlementLock USDC in escrow
03release_escrowRelease or refund

initialize

Initializes the ArPay program with configuration parameters. Must be called once during deployment by the deployer keypair.

Signature:

pub fn initialize(
    ctx: Context<Initialize>,
    authority: Pubkey,
    treasury: Pubkey,
    timeout_seconds: i64,
    max_slippage: u16,
) -> Result<()>

Parameters:

NameTypeDescription
authorityPubkeyPublic key authorized to release escrows
treasuryPubkeyToken account to receive released USDC
timeout_secondsi64Seconds before permissionless refund enabled (default: 120)
max_slippageu16Basis points for rate tolerance (default: 50 = 0.5%)

Accounts:

AccountMutableSignerDescription
configProgram configuration PDA (seeds: ["config"])
deployerProgram deployer (pays initialization)
system_programSolana System Program

Example (TypeScript/Anchor):

const tx = await program.methods
  .initialize(
    authorityKeypair.publicKey,
    treasuryTokenAccount,
    120,  // 120 seconds timeout
    50    // 0.5% max slippage
  )
  .accounts({
    config: configPda,
    deployer: deployerKeypair.publicKey,
    systemProgram: SystemProgram.programId,
  })
  .signers([deployerKeypair])
  .rpc();

Errors:

ConfigAlreadyInitializedProgram already initialized
InvalidAuthorityAuthority pubkey is invalid (zero address)

initiate_settlement

Initiates a new settlement by transferring USDC from the payer's token account into a PDA escrow. Emits SettlementRequested event for oracle processing.

Signature:

pub fn initiate_settlement(
    ctx: Context<InitiateSettlement>,
    merchant_id: String,
    idr_amount: u64,
    usdc_amount: u64,
    nonce: u64,
) -> Result<()>

Parameters:

NameTypeDescription
merchant_idStringNational Merchant ID (NMID) from QRIS payload (max 25 chars)
idr_amountu64Amount in IDR cents (e.g., 10,000,000 = Rp 100,000)
usdc_amountu64Amount in USDC base units (6 decimals, e.g., 6,850,000 = 6.85 USDC)
nonceu64Unique nonce for PDA derivation (prevents replay)

Accounts:

AccountMutableSignerDescription
payerUser submitting transaction
payer_token_accountUser's USDC token account
escrow_accountEscrow PDA (seeds: ["escrow", payer, merchant_id, nonce])
escrow_token_accountEscrow's USDC token account
configProgram configuration PDA
pyth_price_accountPyth USDC/USD price feed account
token_programSPL Token Program
system_programSolana System Program
rentSolana Rent Sysvar

Events Emitted:

#[event]
pub struct SettlementRequested {
    pub merchant_id: String,    // NMID from parameters
    pub idr_amount: u64,        // IDR in cents
    pub usdc_amount: u64,       // USDC in base units (6 decimals)
    pub payer: Pubkey,          // Payer's public key
    pub nonce: u64,             // Nonce for PDA uniqueness
    pub timestamp: i64,         // Unix timestamp
}

Example:

const nonce = Date.now();
const merchantId = "ID1234567890123";
const idrAmount = 100_000 * 100;      // Rp 100,000 → cents
const usdcAmount = 6.85 * 1_000_000; // 6.85 USDC → base units

const [escrowPda] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("escrow"),
    payer.publicKey.toBuffer(),
    Buffer.from(merchantId),
    new anchor.BN(nonce).toArrayLike(Buffer, "le", 8),
  ],
  program.programId
);

const tx = await program.methods
  .initiateSettlement(merchantId, idrAmount, usdcAmount, nonce)
  .accounts({
    payer: payer.publicKey,
    payerTokenAccount: payerUsdcAccount,
    escrowAccount: escrowPda,
    escrowTokenAccount: escrowUsdcAccount,
    config: configPda,
    pythPriceAccount: PYTH_USDC_USD_FEED,
    tokenProgram: TOKEN_PROGRAM_ID,
    systemProgram: SystemProgram.programId,
    rent: SYSVAR_RENT_PUBKEY,
  })
  .signers([payer])
  .rpc();

Validation:

Merchant ID length ≤ 25 characters
USDC amount > 0
IDR amount > 0
Payer token account has sufficient USDC balance
Rate slippage within tolerance (checked against Pyth price)
Escrow PDA not already initialized (nonce uniqueness)

Errors:

InvalidMerchantIdMerchant ID empty or too long
InvalidAmountUSDC or IDR amount is zero
InsufficientFundsPayer token account balance too low
SlippageExceededCurrent rate differs more than max_slippage from encoded rate
AccountAlreadyInitializedEscrow PDA already exists (nonce collision)

release_escrow

Releases USDC from escrow PDA either to the protocol treasury (upon successful fiat disbursement) or back to the payer (upon disbursement failure or timeout).

Signature:

pub fn release_escrow(
    ctx: Context<ReleaseEscrow>,
    to_treasury: bool,
) -> Result<()>

Parameters:

NameTypeDescription
to_treasurybooltrue = release to treasury (requires authority), false = refund to payer (after timeout)

Accounts:

AccountMutableSignerDescription
authorityProgram authority (required if to_treasury=true)
escrow_accountEscrow PDA holding funds
escrow_token_accountEscrow's USDC token account
treasury_token_accountProtocol treasury USDC account (if to_treasury=true)
payer_token_accountOriginal payer's USDC account (if to_treasury=false)
configProgram configuration PDA
token_programSPL Token Program
clockSolana Clock Sysvar

Authorization Logic:

if to_treasury {
    // Release to treasury (disbursement confirmed)
    require!(
        authority.key() == config.authority,
        ErrorCode::UnauthorizedRelease
    );
} else {
    // Refund to payer (timeout expired)
    let clock = Clock::get()?;
    require!(
        clock.unix_timestamp > escrow.created_at + config.timeout_seconds,
        ErrorCode::TimeoutNotReached
    );
}
Treasury Release
Called by oracle after Xendit confirms disbursement
const tx = await program.methods
  .releaseEscrow(true)
  .accounts({
    authority: authorityKeypair.publicKey,
    escrowAccount: escrowPda,
    escrowTokenAccount: escrowUsdcAccount,
    treasuryTokenAccount: treasuryUsdcAccount,
    payerTokenAccount: PublicKey.default,
    config: configPda,
    tokenProgram: TOKEN_PROGRAM_ID,
    clock: SYSVAR_CLOCK_PUBKEY,
  })
  .signers([authorityKeypair])
  .rpc();
Payer Refund
Called by anyone after 120s timeout — no signature required
const tx = await program.methods
  .releaseEscrow(false)
  .accounts({
    authority: PublicKey.default,
    escrowAccount: escrowPda,
    escrowTokenAccount: escrowUsdcAccount,
    treasuryTokenAccount: PublicKey.default,
    payerTokenAccount: payerUsdcAccount,
    config: configPda,
    tokenProgram: TOKEN_PROGRAM_ID,
    clock: SYSVAR_CLOCK_PUBKEY,
  })
  .rpc();

Errors:

UnauthorizedReleaseAuthority signature invalid (if to_treasury=true)
TimeoutNotReachedCurrent time is less than escrow creation time + timeout (if to_treasury=false)
EscrowAlreadyReleasedEscrow PDA already closed

# Account Schemas

Config

Program-wide configuration stored in a PDA with seeds ["config"].

#[account]
pub struct Config {
    pub authority: Pubkey,       // 32 bytes
    pub treasury: Pubkey,        // 32 bytes
    pub timeout_seconds: i64,    // 8 bytes
    pub max_slippage: u16,       // 2 bytes
    pub bump: u8,                // 1 byte
}
// Total size: 8 (discriminator) + 75 = 83 bytes
FieldTypeDescription
authorityPubkeyPublic key authorized to release escrows to treasury
treasuryPubkeyToken account receiving released USDC
timeout_secondsi64Seconds before permissionless refund (default: 120)
max_slippageu16Max rate deviation in basis points (default: 50 = 0.5%)
bumpu8PDA bump seed

Escrow

Individual settlement escrow stored in a PDA with seeds ["escrow", payer, merchant_id, nonce].

#[account]
pub struct Escrow {
    pub payer: Pubkey,         // 32 bytes
    pub merchant_id: String,   // 4 + 25 = 29 bytes (max)
    pub idr_amount: u64,       // 8 bytes
    pub usdc_amount: u64,      // 8 bytes
    pub nonce: u64,            // 8 bytes
    pub created_at: i64,       // 8 bytes
    pub bump: u8,              // 1 byte
}
// Total size: 8 (discriminator) + 94 = 102 bytes
FieldTypeDescription
payerPubkeyOriginal payer's public key (for refund routing)
merchant_idStringNational Merchant ID (NMID) from QRIS
idr_amountu64IDR amount in cents (e.g., 10,000,000 = Rp 100,000)
usdc_amountu64USDC amount in base units (6 decimals)
nonceu64Unique nonce for PDA derivation
created_ati64Unix timestamp of escrow creation
bumpu8PDA bump seed

# Events

SettlementRequested

Emitted by initiate_settlement. Signals the oracle to process fiat disbursement.

#[event]
pub struct SettlementRequested {
    pub merchant_id: String,
    pub idr_amount: u64,
    pub usdc_amount: u64,
    pub payer: Pubkey,
    pub nonce: u64,
    pub timestamp: i64,
}

Example event log:

{
  "type": "SettlementRequested",
  "data": {
    "merchant_id": "ID1234567890123",
    "idr_amount": 10000000,
    "usdc_amount": 6850000,
    "payer": "7xJ9...kL2p",
    "nonce": 1706889600000,
    "timestamp": 1706889600
  }
}

Listening via WebSocket:

async def listen_events():
    async with connect("wss://api.mainnet-beta.solana.com") as websocket:
        await websocket.logs_subscribe(
            filter_={"mentions": [str(ARPAY_PROGRAM_ID)]}
        )
        async for log in websocket:
            if "SettlementRequested" in log.value.logs:
                event_data = parse_event(log.value.logs)
                print(f"New settlement: {event_data}")

# Error Codes

CodeNameDescription
6000ConfigAlreadyInitializedProgram configuration already initialized
6001InvalidAuthorityAuthority public key is invalid
6002InvalidMerchantIdMerchant ID is empty or exceeds 25 characters
6003InvalidAmountUSDC or IDR amount is zero
6004InsufficientFundsPayer token account has insufficient USDC balance
6005SlippageExceededExchange rate slippage exceeds configured tolerance
6006AccountAlreadyInitializedEscrow PDA already exists (nonce collision)
6007UnauthorizedReleaseCaller not authorized to release escrow to treasury
6008TimeoutNotReachedTimeout period not elapsed for permissionless refund
6009EscrowAlreadyReleasedEscrow account already closed
6010InvalidPythPricePyth price feed data is stale or invalid
6011ArithmeticOverflowArithmetic operation overflowed

Handling errors (TypeScript):

try {
  const tx = await program.methods.initiateSettlement(...).rpc();
} catch (error) {
  if (error.code === 6005) {
    console.error("Slippage exceeded! Rate changed too much.");
    // Refresh rate and retry
  } else if (error.code === 6004) {
    console.error("Insufficient USDC balance");
    // Prompt user to deposit more
  } else {
    console.error("Unknown error:", error);
  }
}

# Constants

pub const USDC_MINT: Pubkey = pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
pub const PYTH_USDC_USD_FEED: Pubkey = pubkey!("Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD");

pub const DEFAULT_TIMEOUT_SECONDS: i64 = 120;
pub const DEFAULT_MAX_SLIPPAGE: u16 = 50;  // 0.5%

pub const MAX_MERCHANT_ID_LENGTH: usize = 25;

# PDA Derivations

Config PDA

const [configPda, configBump] = PublicKey.findProgramAddressSync(
  [Buffer.from("config")],
  program.programId
);

Escrow PDA

const [escrowPda, escrowBump] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("escrow"),
    payerPublicKey.toBuffer(),
    Buffer.from(merchantId),
    new anchor.BN(nonce).toArrayLike(Buffer, "le", 8),
  ],
  program.programId
);

# Integration Examples

React Hook for Settlements

import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { Program, AnchorProvider } from '@coral-xyz/anchor';
import { useState } from 'react';

export function useArPaySettlement() {
  const { connection } = useConnection();
  const wallet = useWallet();
  const [loading, setLoading] = useState(false);

  const initiateSettlement = async (
    merchantId: string,
    idrAmount: number,
    usdcAmount: number
  ) => {
    if (!wallet.publicKey) throw new Error("Wallet not connected");

    setLoading(true);
    try {
      const provider = new AnchorProvider(connection, wallet as any, {});
      const program = new Program(IDL, PROGRAM_ID, provider);

      const nonce = Date.now();
      const tx = await program.methods
        .initiateSettlement(
          merchantId,
          idrAmount * 100,        // Convert to cents
          usdcAmount * 1_000_000, // Convert to base units
          nonce
        )
        .accounts({
          // ... derived accounts
        })
        .rpc();

      return { success: true, signature: tx };
    } catch (error) {
      return { success: false, error };
    } finally {
      setLoading(false);
    }
  };

  return { initiateSettlement, loading };
}

# Testing

Unit Tests (Anchor)

describe("ArPay", () => {
  it("Initializes program configuration", async () => {
    const tx = await program.methods
      .initialize(authority, treasury, 120, 50)
      .accounts({ /* ... */ })
      .rpc();

    const config = await program.account.config.fetch(configPda);
    expect(config.authority.toString()).to.equal(authority.toString());
  });

  it("Creates settlement and locks USDC", async () => {
    const balanceBefore = await getTokenBalance(payerTokenAccount);

    await program.methods
      .initiateSettlement("ID123", 10000000, 6850000, Date.now())
      .accounts({ /* ... */ })
      .rpc();

    const balanceAfter = await getTokenBalance(payerTokenAccount);
    expect(balanceBefore - balanceAfter).to.equal(6850000);
  });

  it("Refunds after timeout", async () => {
    // ... test timeout refund logic
  });
});

# Security Considerations

⚠️
Always validate nonce uniqueness — Use timestamps + random salt to prevent replay attacks
⚠️
Check Pyth price staleness — Pyth publishes every 400ms; reject feeds older than 2s
⚠️
Handle timeout gracefully — UI should show countdown timer and surface refund option
⚠️
Rate-limit settlement attempts — Prevent spam and denial-of-service attacks
⚠️
Monitor escrow balances — Alert if total locked USDC exceeds configured threshold

# Support & Resources

For bugs or questions, open an issue on GitHub.