Skip to content

Puneethkumarck/stablepay-hackathon

Repository files navigation

StablePay

CI Java Spring Boot Stripe Solana Anchor Go

Instant cross-border remittances on Solana. No seed phrases. No app for recipients. Guaranteed delivery.

StablePay — Instant cross-border remittances on Solana

StablePay is a consumer-facing remittance application for the USD → INR corridor, built on USDC/Solana. It combines MPC wallet abstraction, a custom Anchor escrow program, and Temporal durable workflows to deliver a seamless sender-to-recipient experience — the recipient claims funds via an SMS link, no crypto knowledge required.

Built for the Colosseum Frontier Hackathon (April 6 – May 11, 2026)


The Problem We're Solving

Traditional vs StablePay — Cross-border payment comparison

Verified on-chain: Each remittance costs $0.002 total (3 transactions × 0.000005 SOL). E2E tested with 10/10 customers completing in 22–60 seconds.


Architecture Overview

StablePay Platform Architecture

graph TB
    subgraph "Application Layer"
        A[REST API — Spring MVC]
        A1["/api/wallets"]
        A2["/api/remittances"]
        A3["/api/fx"]
        A4["/api/claims"]
        A5["/api/funding-orders"]
        A6["/webhooks/stripe"]
        A7["/api/auth"]
    end

    subgraph "Domain Layer — Zero Framework Dependencies"
        B[Handlers — Use Cases]
        C[Models — Immutable Records]
        D[Ports — Interfaces]
        E[Exceptions — SP-XXXX Codes]
    end

    subgraph "Infrastructure Adapters"
        F[JPA + PostgreSQL + Flyway]
        G[MPC gRPC Client]
        H[Solana RPC — sol4k]
        I[ExchangeRate-API + Redis Cache]
        J[Temporal Workflows]
        K[Twilio SMS]
        L[Razorpay UPI Disbursement]
        S[Stripe Payments]
        T[Google OAuth + JWT Auth]
    end

    subgraph "External Systems"
        M[MPC Sidecar x2 — Go + tss-lib]
        N[Solana Devnet — Anchor Escrow]
        O[open.er-api.com]
        P[Twilio API]
        Q[Razorpay API]
        R[Stripe API]
    end

    A --> B
    B --> D
    D --> F
    D --> G
    D --> H
    D --> I
    D --> J
    D --> K
    D --> L
    D --> S
    D --> T
    G --> M
    H --> N
    I --> O
    K --> P
    L --> Q
    S --> R
Loading

Dependency rule: domain → nothing. applicationdomain. infrastructuredomain. Never the reverse.


The Payment Lifecycle: Step by Step

StablePay Payment Lifecycle

sequenceDiagram
    participant Sender as Sender (Mobile/API)
    participant API as StablePay API
    participant Stripe as Stripe
    participant MPC as MPC Sidecar x2
    participant DB as PostgreSQL
    participant Temporal as Temporal Workflow
    participant Solana as Solana Devnet
    participant SMS as Twilio SMS
    participant Recipient as Recipient (Web)
    participant Razorpay as Razorpay UPI

    Note over Sender,API: Use Case 0 — Social Login (Google)
    Sender->>API: POST /api/auth/social {provider: "GOOGLE", idToken}
    API->>API: Verify Google ID token (JWKS)
    API->>DB: Upsert user + social_identity
    API->>MPC: gRPC GenerateKey (DKG ceremony — first login only)
    MPC-->>API: solanaAddress + publicKey + keyShareData
    API->>DB: INSERT wallet (first login only)
    API-->>Sender: 201/200 {accessToken, refreshToken, user, wallet}

    Note over Sender,Stripe: Use Case 1 — Fund Wallet (Stripe On-Ramp)
    Sender->>API: POST /api/wallets/{id}/fund {amount} + Bearer token
    API->>Stripe: Create PaymentIntent
    Stripe-->>API: clientSecret + paymentIntentId
    API->>DB: INSERT funding_order (PAYMENT_CONFIRMED)
    API-->>Sender: 201 {fundingId, clientSecret}
    Stripe->>API: Webhook: payment_intent.succeeded
    API->>Temporal: Start WalletFundingWorkflow
    Temporal->>Solana: Transfer SOL (rent) + create ATA + transfer USDC
    Temporal->>DB: UPDATE funding_order → FUNDED

    Note over Sender,API: Use Case 2 — Get FX Rate
    Sender->>API: GET /api/fx/USD-INR
    API->>API: Check Redis cache
    API-->>Sender: {rate: 84.50, source, expiresAt}

    Note over Sender,Razorpay: Use Case 3 — Send Remittance
    Sender->>API: POST /api/remittances {phone, amount} + Bearer token
    API->>DB: Reserve sender balance
    API->>API: Lock FX rate, calculate INR
    API->>DB: INSERT remittance (INITIATED)
    API->>DB: INSERT claim_token (48h expiry)
    API->>Temporal: Start RemittanceLifecycleWorkflow
    API-->>Sender: 201 {remittanceId, status: INITIATED}

    Note over Temporal,Solana: Workflow Phase 1 — Escrow
    Temporal->>MPC: Sign escrow deposit transaction
    MPC-->>Temporal: Ed25519 signature
    Temporal->>Solana: Submit deposit (USDC → PDA vault)
    loop Poll getSignatureStatuses (3s interval, max 40 attempts)
        Temporal->>Solana: Check confirmation status
        Solana-->>Temporal: PROCESSED / CONFIRMED / FINALIZED
    end
    Temporal->>DB: UPDATE status → ESCROWED

    Note over Temporal,SMS: Workflow Phase 2 — Notify
    Temporal->>SMS: Send claim link via SMS
    SMS-->>Recipient: "Claim your funds: https://..."

    Note over Temporal,Recipient: Workflow Phase 3 — Wait
    Temporal->>Temporal: Await claim signal (48h timeout)

    Note over Recipient,Razorpay: Use Case 4 — Claim Funds
    Recipient->>API: GET /api/claims/{token}
    API-->>Recipient: {amountUsdc, amountInr, fxRate}
    Recipient->>API: POST /api/claims/{token} {upiId}
    API->>DB: UPDATE claim_token (claimed=true, upiId)
    API->>Temporal: Signal claimSubmitted(upiId)

    Note over Temporal,Razorpay: Workflow Phase 4 — Deliver
    Temporal->>Solana: Release escrow to recipient
    loop Poll getSignatureStatuses (3s interval, max 40 attempts)
        Temporal->>Solana: Check confirmation status
    end
    Temporal->>DB: UPDATE status → CLAIMED
    Temporal->>Razorpay: Disburse INR to UPI
    Temporal->>DB: UPDATE status → DELIVERED
Loading

Use Case 0: Social Login + Wallet Creation

Google sign-in, instant wallet. On first login the backend verifies the Google ID token, creates a user record, and runs a 2-of-2 MPC DKG ceremony to produce an Ed25519 Solana wallet. No seed phrases. Returning users get their existing wallet.

sequenceDiagram
    participant Client
    participant Controller as AuthController
    participant Handler as SocialLoginHandler
    participant Google as GoogleIdTokenVerifier
    participant UserRepo as UserRepository
    participant WalletHandler as CreateWalletHandler
    participant MPC as MpcWalletGrpcClient
    participant Sidecar0 as MPC Sidecar 0
    participant Sidecar1 as MPC Sidecar 1
    participant JWT as JwtTokenIssuer

    Client->>Controller: POST /api/auth/social {provider: "GOOGLE", idToken}
    Controller->>Handler: handle("GOOGLE", idToken, ip, userAgent)
    Handler->>Google: verify(idToken)
    Google-->>Handler: {sub, email, email_verified}

    alt New user
        Handler->>UserRepo: save(User{id: UUID, email})
        Handler->>WalletHandler: handle(userId)
        WalletHandler->>MPC: generateKey()
        MPC->>Sidecar0: gRPC GenerateKey (ceremonyId, threshold=1, parties=2)
        Sidecar0->>Sidecar1: P2P DKG round messages (port 7000↔7001)
        Sidecar1->>Sidecar0: P2P DKG round messages
        Note over Sidecar0,Sidecar1: Ed25519 DKG ceremony completes
        Sidecar0-->>MPC: {solanaAddress, publicKey, keyShareData}
        MPC-->>WalletHandler: GeneratedKey
    else Returning user
        Handler->>UserRepo: findBySocialIdentity(provider, sub)
        UserRepo-->>Handler: existing User + Wallet
    end

    Handler->>JWT: issue(userId)
    JWT-->>Handler: accessToken + refreshToken
    Handler-->>Controller: LoginResult
    Controller-->>Client: 201 Created (new) / 200 OK (returning)
Loading
POST /api/auth/social
Content-Type: application/json

{ "provider": "GOOGLE", "idToken": "<google-id-token>" }
{
  "accessToken": "<jwt>",
  "refreshToken": "<opaque-token>",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "user": {
    "id": "7d4718ba-a6f3-485c-b89b-77afa2caf206",
    "email": "user@gmail.com"
  },
  "wallet": {
    "id": 16,
    "solanaAddress": "CrsMdkbkAQRz7srMgeTe9sanoiHkeQBCKnhhVR9DAd18",
    "availableBalance": 0,
    "totalBalance": 0,
    "createdAt": "2026-04-22T06:52:59.379393834Z",
    "updatedAt": "2026-04-22T06:52:59.379393834Z"
  }
}

Additional Auth Endpoints

Endpoint Auth Description
POST /api/auth/refresh Public Rotate refresh token, issue new access token
POST /api/auth/logout Bearer JWT Revoke all refresh tokens (204 No Content)
GET /api/wallets/me Bearer JWT Get authenticated user's wallet

What Happens Inside the MPC Sidecars

┌─────────────────────────────────────────────────────────────────┐
│                   MPC Key Generation (DKG)                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. Backend sends gRPC GenerateKey to Sidecar 0 (port 50051)    │
│                                                                  │
│  2. Sidecar 0 registers ceremony in P2P CeremonyRegistry        │
│     └─ Creates buffered channel for round messages               │
│                                                                  │
│  3. Both sidecars run tss-lib Ed25519 DKG protocol               │
│     ├─ Round 1: Commitment exchange (P2P port 7000↔7001)         │
│     ├─ Round 2: Share distribution                               │
│     └─ Round 3: Key derivation                                   │
│                                                                  │
│  4. Result: Both parties hold a key share                        │
│     ├─ Neither party has the full private key                    │
│     ├─ Public key (Ed25519) derived cooperatively                │
│     └─ Solana address = Base58(publicKey)                        │
│                                                                  │
│  5. Sidecar 0 returns to backend:                                │
│     ├─ solanaAddress: Base58-encoded Solana address              │
│     ├─ publicKey: Raw Ed25519 public key bytes                   │
│     └─ keyShareData: Serialized key share (stored in DB)         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Error Paths

Condition Error Code HTTP
User already has a wallet SP-0008 409 Conflict
MPC ceremony fails SP-0010 500
gRPC timeout (>30s) SP-0010 500

Use Case 1: Fund Wallet (Stripe On-Ramp)

Real Stripe integration. The sender pays via Stripe (card), which triggers a webhook. A Temporal workflow then transfers SOL (for rent/fees), creates an Associated Token Account, and transfers USDC from the treasury to the sender's MPC wallet on-chain.

sequenceDiagram
    participant Client
    participant Controller as FundingController
    participant Handler as InitiateFundingHandler
    participant Writer as FundingOrderWriter
    participant Stripe as StripePaymentAdapter
    participant DB as PostgreSQL
    participant Webhook as StripeWebhookController
    participant Complete as CompleteFundingHandler
    participant Temporal as WalletFundingWorkflow
    participant Treasury as TreasuryServiceAdapter
    participant Solana as Solana Devnet

    Client->>Controller: POST /api/wallets/1/fund {amount: 1.00}
    Controller->>Writer: persist funding order
    Writer->>DB: INSERT funding_order (PAYMENT_CONFIRMED)
    Controller->>Handler: initiate funding
    Handler->>Stripe: Create PaymentIntent ($1.00)
    Stripe-->>Handler: paymentIntentId + clientSecret
    Handler-->>Client: 201 {fundingId, clientSecret}

    Note over Stripe,Webhook: Stripe fires webhook
    Stripe->>Webhook: POST /webhooks/stripe (payment_intent.succeeded)
    Webhook->>Complete: handle(fundingId)
    Complete->>Temporal: Start WalletFundingWorkflow

    Note over Temporal,Solana: Temporal orchestrates on-chain funding
    Temporal->>Treasury: checkTreasuryBalance(1.00 USDC)
    Temporal->>Treasury: ensureSolBalance(senderAddress)
    Treasury->>Solana: Transfer SOL for rent + fees
    Temporal->>Treasury: createAtaIfNeeded(senderAddress)
    Treasury->>Solana: Create Associated Token Account
    Temporal->>Treasury: transferUsdc(senderAddress, 1.00)
    Treasury->>Solana: SPL token transfer (treasury → sender ATA)
    Temporal->>DB: UPDATE funding_order → FUNDED
Loading
POST /api/wallets/1/fund
Content-Type: application/json

{ "amount": 1.00 }
{
  "fundingId": "b2c8a29a-fcca-487f-979b-bf355c82faeb",
  "stripePaymentIntentId": "pi_3TO0YZ3nnME1dfOB0dz1SICr",
  "stripeClientSecret": "pi_...secret_...",
  "walletId": 1,
  "amountUsdc": 1.00,
  "status": "PAYMENT_CONFIRMED"
}

Funding Order Status Machine

PAYMENT_CONFIRMED → FUNDED     (webhook + Temporal workflow succeeds)
PAYMENT_CONFIRMED → FAILED     (webhook: payment_intent.payment_failed)
FUNDED → REFUND_INITIATED      (manual refund)
REFUND_INITIATED → REFUNDED    (refund completed)

Error Paths

Condition Error Code HTTP
Wallet not found SP-0006 404
Treasury balance insufficient SP-0007 503
Funding already in progress for wallet SP-0022 409
Stripe PaymentIntent creation failed SP-0021 502

Use Case 2: Get FX Rate

Real-time rates with fallback. FX rates come from ExchangeRate-API with Redis caching (5-minute TTL). If the API is unreachable, a hardcoded fallback rate of 84.50 is used.

sequenceDiagram
    participant Client
    participant Controller as FxRateController
    participant Handler as GetFxRateQueryHandler
    participant Adapter as ExchangeRateApiAdapter
    participant Redis as Redis Cache
    participant API as open.er-api.com

    Client->>Controller: GET /api/fx/USD-INR
    Controller->>Handler: handle("USD", "INR")
    Handler->>Adapter: getRate("USD", "INR")

    alt Cache Hit
        Adapter->>Redis: GET fxRate::USD
        Redis-->>Adapter: Cached FxQuote
    else Cache Miss
        Adapter->>API: GET /v6/latest/USD
        API-->>Adapter: {rates: {INR: 84.50, ...}}
        Adapter->>Redis: SET fxRate::USD (5m TTL)
    else API Failure
        Note over Adapter: Fallback: rate=84.50, source="fallback"
    end

    Adapter-->>Handler: FxQuote{rate, source, timestamp, expiresAt}
    Handler-->>Controller: FxQuote
    Controller-->>Client: 200 OK
Loading
GET /api/fx/USD-INR
{
  "rate": 84.500000,
  "source": "open.er-api.com",
  "timestamp": "2026-04-13T10:00:00Z",
  "expiresAt": "2026-04-13T10:01:00Z"
}

Error Paths

Condition Error Code HTTP
Unsupported corridor (e.g., EUR-INR) SP-0009 400

Use Case 3: Send Remittance

The core flow. Reserves the sender's balance, locks the FX rate, generates a claim token, and starts a Temporal durable workflow that orchestrates the entire escrow-to-delivery lifecycle.

sequenceDiagram
    participant Client
    participant Handler as CreateRemittanceHandler
    participant WalletRepo as WalletRepository
    participant FxProvider as ExchangeRateApiAdapter
    participant RemitRepo as RemittanceRepository
    participant ClaimRepo as ClaimTokenRepository
    participant Temporal as TemporalWorkflowStarter
    participant DB as PostgreSQL

    Client->>Handler: handle(principalId, "+919876543210", 100.00)

    Note over Handler,DB: Step 1 — Reserve Balance
    Handler->>WalletRepo: findByUserId(principalId)
    WalletRepo-->>Handler: Wallet{available: 100.00}
    Handler->>Handler: wallet.reserveBalance(100.00)
    Note over Handler: available: 100→0, total: 100 (unchanged)
    Handler->>WalletRepo: save(reserved wallet)

    Note over Handler,FxProvider: Step 2 — Lock FX Rate
    Handler->>FxProvider: getRate("USD", "INR")
    FxProvider-->>Handler: FxQuote{rate: 84.50}
    Handler->>Handler: INR = 100.00 × 84.50 = ₹8,450.00

    Note over Handler,DB: Step 3 — Create Remittance
    Handler->>Handler: remittanceId = UUID.randomUUID()
    Handler->>RemitRepo: save(Remittance{status: INITIATED})
    RemitRepo->>DB: INSERT INTO remittances

    Note over Handler,DB: Step 4 — Generate Claim Token
    Handler->>Handler: token = UUID.randomUUID()
    Handler->>ClaimRepo: save(ClaimToken{expires: now+48h})
    ClaimRepo->>DB: INSERT INTO claim_tokens
    Handler->>RemitRepo: save(remittance + claimTokenId)

    Note over Handler,Temporal: Step 5 — Start Workflow
    Handler->>Temporal: startWorkflow(remittanceId, senderAddr, phone, amount, token)
    Temporal-->>Handler: Workflow started (async)

    Handler-->>Client: 201 Created
Loading
POST /api/remittances
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "recipientPhone": "+919876543210",
  "amountUsdc": 1.00
}
{
  "id": 10,
  "remittanceId": "8ce317d2-639e-4054-bcae-204706dc2c9a",
  "recipientPhone": "+919876543210",
  "amountUsdc": 1.0,
  "amountInr": 93.61,
  "fxRate": 93.605785,
  "status": "INITIATED",
  "escrowPda": null,
  "claimTokenId": "82a56560-ad6f-4b97-a26d-12c36b722f58",
  "smsNotificationFailed": false,
  "createdAt": "2026-04-22T06:53:15.632889681Z",
  "updatedAt": "2026-04-22T06:53:15.646040707Z",
  "expiresAt": null
}

What Happens After the API Returns

The Temporal workflow takes over asynchronously. The sender gets an immediate response, and the workflow progresses through the escrow lifecycle in the background.

Error Paths

Condition Error Code HTTP
Sender wallet not found SP-0006 404
Insufficient balance SP-0002 400
Unsupported corridor SP-0009 400

Temporal Workflow: The Remittance Lifecycle

Guaranteed delivery. If the process crashes at any point, Temporal resumes exactly where it left off. Every remittance reaches a terminal state — delivered, refunded, or failed.

stateDiagram-v2
    [*] --> INITIATED: POST /api/remittances
    INITIATED --> ESCROWED: depositEscrow activity succeeds

    ESCROWED --> CLAIMED: Recipient claims within 48h
    ESCROWED --> REFUNDED: 48h timeout — no claim

    CLAIMED --> DELIVERED: INR disbursement succeeds
    CLAIMED --> DISBURSEMENT_FAILED: INR disbursement fails

    INITIATED --> FAILED: Deposit escrow fails

    note right of ESCROWED
        USDC locked in Solana PDA
        SMS sent to recipient
        Workflow awaits claim signal
    end note

    note right of DELIVERED
        Escrow released on-chain
        INR sent to UPI
        Terminal state
    end note

    note right of REFUNDED
        USDC returned to sender
        Escrow closed on-chain
        Terminal state
    end note

    note right of DISBURSEMENT_FAILED
        Escrow released (irreversible)
        INR payout failed
        Requires manual resolution
    end note
Loading

Workflow Activities — In Execution Order

┌─────────────────────────────────────────────────────────────────┐
│              RemittanceLifecycleWorkflow.execute()                │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Phase 1: ESCROW DEPOSIT                                         │
│  ├─ Activity: depositEscrow (60s timeout, 3 retries, 2s backoff)│
│  │   ├─ Fetch wallet's keyShareData from DB                     │
│  │   ├─ Build Solana escrow deposit instruction                  │
│  │   ├─ MPC-sign transaction (gRPC → sidecar)                   │
│  │   └─ Submit to Solana devnet → returns signature              │
│  ├─ Poll: awaitTransactionConfirmation(signature)                │
│  │   ├─ Calls getSignatureStatuses RPC every 3 seconds           │
│  │   ├─ Max 40 attempts (~120s total polling window)             │
│  │   ├─ Accepts CONFIRMED or FINALIZED as success                │
│  │   └─ Throws SP-0012 on timeout, SP-0031 on on-chain failure  │
│  └─ Status Update: INITIATED → ESCROWED                         │
│                                                                  │
│  Phase 2: SMS NOTIFICATION                                       │
│  ├─ Activity: sendClaimSms (30s timeout, 3 retries, 5s backoff) │
│  │   ├─ Build claim URL: {claimBaseUrl}/{claimToken}             │
│  │   └─ Send via Twilio (or log in dev mode)                     │
│  └─ On failure: set smsNotificationFailed=true, continue         │
│                                                                  │
│  Phase 3: AWAIT CLAIM                                            │
│  ├─ Workflow.await(48 hours, () -> claimReceived)                │
│  │                                                               │
│  │   ┌─ PATH A: Claim signal received ──────────────────────┐   │
│  │   │  Activity: releaseEscrow (60s, 3 retries, 2s backoff)│   │
│  │   │  Poll: awaitTransactionConfirmation (3s × 40 max)     │   │
│  │   │  Status Update: ESCROWED → CLAIMED                    │   │
│  │   │  Activity: disburseInr (45s, NO retry) via Razorpay   │   │
│  │   │  ├─ Success: Status → DELIVERED                       │   │
│  │   │  └─ Failure: Status → DISBURSEMENT_FAILED             │   │
│  │   └──────────────────────────────────────────────────────┘   │
│  │                                                               │
│  │   ┌─ PATH B: 48h timeout — no claim ────────────────────┐   │
│  │   │  Activity: refundEscrow (60s, 3 retries, 2s backoff) │   │
│  │   │  Poll: awaitTransactionConfirmation (3s × 40 max)     │   │
│  │   │  Status Update: ESCROWED → REFUNDED                   │   │
│  │   └──────────────────────────────────────────────────────┘   │
│  └                                                               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Re-Sign on Retry

Solana blockhashes expire in ~60 seconds. If a deposit or release fails and retries, the workflow requests a fresh MPC signature with a new blockhash — it never replays a stale transaction.

Disbursement Does Not Retry

Once escrow is released on-chain, the USDC is gone. If the INR disbursement fails after release, retrying could cause duplicate payouts. The workflow marks the status as DISBURSEMENT_FAILED for manual resolution.


Use Case 4: Claim Funds (Recipient)

No app required. The recipient opens an SMS link, sees how much they'll receive in INR, enters their UPI ID, and submits. The Temporal workflow wakes up and completes the delivery.

Step 1: View Claim Details

sequenceDiagram
    participant Recipient
    participant API as ClaimController
    participant Handler as GetClaimQueryHandler
    participant ClaimRepo as ClaimTokenRepository
    participant RemitRepo as RemittanceRepository

    Recipient->>API: GET /api/claims/{token}
    API->>Handler: handle(token)
    Handler->>ClaimRepo: findByToken(token)
    ClaimRepo-->>Handler: ClaimToken{remittanceId, expiresAt}
    Handler->>RemitRepo: findByRemittanceId(remittanceId)
    RemitRepo-->>Handler: Remittance{amountUsdc: 100, amountInr: 8450, fxRate: 84.50}
    Handler-->>API: ClaimDetails
    API-->>Recipient: 200 OK
Loading
GET /api/claims/a1b2c3d4-token-uuid

Step 2: Submit Claim with UPI ID

sequenceDiagram
    participant Recipient
    participant API as ClaimController
    participant Handler as SubmitClaimHandler
    participant ClaimRepo as ClaimTokenRepository
    participant RemitRepo as RemittanceRepository
    participant Signaler as TemporalClaimSignaler
    participant Workflow as RemittanceLifecycleWorkflow

    Recipient->>API: POST /api/claims/{token} {upiId: "raj@upi"}
    API->>Handler: handle(token, "raj@upi")

    Note over Handler: Validation Chain
    Handler->>ClaimRepo: findByToken(token)
    Handler->>Handler: ✓ Token exists (else SP-0011)
    Handler->>Handler: ✓ Not already claimed (else SP-0012)
    Handler->>Handler: ✓ Not expired (else SP-0013)
    Handler->>RemitRepo: findByRemittanceId(...)
    Handler->>Handler: ✓ Remittance exists (else SP-0010)
    Handler->>Handler: ✓ Status == ESCROWED (else SP-0014)

    Handler->>ClaimRepo: save(claimed=true, upiId="raj@upi")
    Handler->>Signaler: signalClaim(remittanceId, token, "raj@upi")

    Signaler->>Signaler: Resolve claim authority Solana address
    Signaler->>Workflow: claimSubmitted(ClaimSignal)
    Note over Workflow: claimReceived=true → unblocks await

    Handler-->>API: ClaimDetails
    API-->>Recipient: 200 OK
Loading
POST /api/claims/a1b2c3d4-token-uuid
Content-Type: application/json

{ "upiId": "raj@upi" }

After the Signal: Workflow Completes Delivery

┌──────────────────────────────────────────────────────────────┐
│         What Happens After claimSubmitted Signal               │
├──────────────────────────────────────────────────────────────┤
│                                                                │
│  1. Workflow.await() unblocks (claimReceived = true)           │
│                                                                │
│  2. releaseEscrow activity                                     │
│     ├─ Build Solana claim instruction                          │
│     ├─ Transfer USDC from escrow PDA → destination address     │
│     ├─ Close vault token account (reclaim rent)                │
│     └─ Escrow status on-chain: Active → Claimed                │
│                                                                │
│  3. Status update: ESCROWED → CLAIMED                          │
│                                                                │
│  4. disburseInr activity                                       │
│     ├─ Call Razorpay Payout API                                │
│     ├─ Transfer INR to recipient's UPI ID                      │
│     └─ INR credited to recipient's bank via UPI                │
│                                                                │
│  5. Status update: CLAIMED → DELIVERED ✓                       │
│                                                                │
└──────────────────────────────────────────────────────────────┘

Claim Validation Rules

# Check Fails With HTTP
1 Token exists in database SP-0011 404
2 Token not already claimed SP-0012 409
3 Token not expired (48h window) SP-0013 410
4 Remittance exists SP-0010 404
5 Remittance status is ESCROWED SP-0014 409

On-Chain Escrow Program

StablePay Solana Escrow Architecture

Program ID: 7C2zsbhgDnxQuC1Nd2rzXQfsfnKazQWFpoUJNqS8zWij

Custom Anchor program managing USDC escrow on Solana devnet.

stateDiagram-v2
    [*] --> Active: deposit(amount, deadline)
    Active --> Claimed: claim() — by claim authority
    Active --> Refunded: refund() — after deadline
    Active --> Cancelled: cancel() — by sender

    note right of Active
        USDC locked in PDA vault
        seeds: ["escrow", remittance_id]
    end note
Loading

Instructions

Instruction Caller What It Does
deposit(amount, deadline) Sender (MPC-signed) Create escrow PDA, transfer USDC to vault, set 48h deadline
claim() Backend (claim authority) Transfer vault USDC to recipient, close accounts
refund() Anyone (after deadline) Return vault USDC to sender, close accounts
cancel() Sender only Return USDC before claim, close accounts

Escrow Account

pub struct Escrow {
    pub sender: Pubkey,           // Sender wallet (MPC-derived)
    pub claim_authority: Pubkey,  // Backend authority for claim
    pub mint: Pubkey,             // USDC mint address
    pub amount: u64,              // Locked amount (6 decimals)
    pub deadline: i64,            // Unix timestamp for refund eligibility
    pub status: EscrowStatus,     // Active | Claimed | Refunded | Cancelled
    pub bump: u8,                 // Canonical PDA bump
    pub remittance_id: Pubkey,    // Links on-chain to off-chain
}

PDA Derivation

Account Seeds
Escrow ["escrow", remittance_id]
Vault ["vault", escrow_pubkey]

Solana Accounting Model

For a detailed visual walkthrough of every wallet, PDA, and token account involved in a remittance — from program deployment through escrow deposit, claim, and INR disbursement — see Solana Accounting Model.

Covers: account types (wallets, ATAs, PDAs, mints), the full lifecycle of a $25 remittance (8 acts), final ledger state, the refund path, and why PDAs enable trustless escrow.


Complete Error Code Reference

Code HTTP Exception Description
SP-0002 400 InsufficientBalanceException Wallet balance too low for remittance
SP-0003 400 MethodArgumentNotValidException Request validation failure
SP-0006 404 WalletNotFoundException Wallet not found by ID or userId
SP-0007 503 TreasuryDepletedException Treasury has insufficient funds
SP-0008 409 WalletAlreadyExistsException Wallet already exists for userId
SP-0009 400 UnsupportedCorridorException Currency pair not supported
SP-0010 404/500 RemittanceNotFoundException / MpcKeyGenerationException Remittance not found / MPC ceremony failed
SP-0011 404 ClaimTokenNotFoundException Claim token not found
SP-0012 409/500 ClaimAlreadyClaimedException / SolanaTransactionException Claim already submitted / TX confirmation timeout
SP-0013 410 ClaimTokenExpiredException Claim token past 48h expiry
SP-0014 409 InvalidRemittanceStateException Invalid state for operation
SP-0016 409 InvalidRemittanceStateException Invalid status transition
SP-0017 500 SmsDeliveryException SMS delivery failed
SP-0018 502 DisbursementException INR disbursement failed
SP-0020 404 FundingOrderNotFoundException Funding order not found
SP-0021 502 FundingFailedException Stripe payment / funding failed
SP-0022 409 FundingAlreadyInProgressException Funding already in progress for wallet
SP-0026 400 InvalidWebhookSignatureException Stripe webhook signature invalid
SP-0031 500 SolanaTransactionException Transaction failed on-chain
SP-0032 401 InvalidIdTokenException Invalid Google ID token
SP-0033 401 EmailNotVerifiedException Google email not verified
SP-0034 400 UnsupportedAuthProviderException Unsupported auth provider (only GOOGLE)
SP-0035 401 InvalidRefreshTokenException Invalid refresh token
SP-0036 401 RefreshTokenExpiredException Refresh token expired
SP-0040 401 SecurityAuthenticationEntryPoint Authentication required (missing/invalid JWT)

Quick Start

Prerequisites

  • Java 25 (sdk install java 25-tem)
  • Docker + Docker Compose
  • Go 1.26 (for MPC sidecar)
  • Solana CLI 2.2.7 + Anchor CLI 0.32.1 (for on-chain program)
  • Node.js 22+ (for Anchor tests)

Full Stack (Docker Compose)

make up
============================================
  StablePay Dev Stack
============================================
  Backend API:    http://localhost:8080
  Swagger UI:     http://localhost:8080/swagger-ui.html
  Health:         http://localhost:8080/actuator/health
  Temporal UI:    http://localhost:8088
  PostgreSQL:     localhost:5432
  Redis:          localhost:6379
  MPC Sidecar 0:  localhost:50051 (gRPC)
  MPC Sidecar 1:  localhost:50052 (gRPC)
============================================

Infrastructure Only (Local Backend Dev)

make infra
cd backend && ./gradlew bootRun

Individual Components

# Backend — compile + format + all tests
cd backend && ./gradlew build

# Anchor program
anchor build && anchor test

# MPC sidecar
cd mpc-sidecar && go build ./... && go test ./... -v -count=1 -timeout 120s

Makefile Targets

Target Description
make up Build backend + start full Docker Compose stack (8 services)
make down Stop all services
make infra Start infrastructure only (for local backend dev)
make logs Follow Docker Compose logs
make clean Stop all services and remove volumes

Try the API

A Postman collection is available at docs/StablePay.postman_collection.json.

Interactive Swagger UI: http://localhost:8080/swagger-ui.html


Testing

# Backend: all tests + formatting
cd backend && ./gradlew build

# Unit tests only
cd backend && ./gradlew test

# Integration tests with TestContainers
cd backend && ./gradlew integrationTest

# Anchor program tests (799 lines, TypeScript on localnet)
anchor test

# MPC sidecar tests
cd mpc-sidecar && go test ./... -v -count=1 -timeout 120s

CI Pipeline

GitHub Actions runs 7 jobs on every push to main and every PR:

graph TD
    A[Spotless Check] --> B[Unit Tests]
    A --> C[Integration Tests]
    B --> D[Build JAR]
    C --> D
    E[MPC Sidecar Tests]
    F[Anchor Build] --> G[Anchor Tests]
Loading

Tech Stack

Component Technology
Backend Java 25, Spring Boot 4.0.5, Spring MVC, JPA
Database PostgreSQL 18, Flyway 12.3
Cache Redis 8
Workflows Temporal 1.29.5 (SDK 1.34.0)
On-chain Rust, Anchor 0.32.1, Solana 2.2.7 (devnet)
MPC Go 1.26, bnb-chain/tss-lib (fystack fork)
Solana SDK sol4k 0.7.0
On-ramp Stripe (card payments + webhooks)
Off-ramp Razorpay UPI Payouts
SMS Twilio 11.3.6
Mapping MapStruct 1.6.3
Resilience Resilience4j 2.3.0
API Docs springdoc-openapi 3.0.2
Build Gradle 9.4.1 (Kotlin DSL), Jib
Testing JUnit 5, BDDMockito, AssertJ, ArchUnit 1.4.1, TestContainers 1.21.4
CI GitHub Actions (7 jobs)

Project Structure

stablepay-hackathon/
├── backend/                          # Spring Boot API
│   └── src/
│       ├── main/java/com/stablepay/
│       │   ├── application/          # Controllers, DTOs, config
│       │   ├── domain/               # Models, handlers, ports
│       │   │   ├── auth/             #   Social login, JWT, refresh tokens
│       │   │   ├── wallet/           #   MPC wallet management
│       │   │   ├── remittance/       #   Core remittance flow
│       │   │   ├── funding/          #   Stripe funding orders
│       │   │   ├── claim/            #   SMS claim tokens
│       │   │   ├── fx/               #   FX rate quotes
│       │   │   └── common/           #   Shared ports (SMS, disbursement)
│       │   └── infrastructure/       # Adapters
│       │       ├── db/               #   JPA + Flyway (8 migrations)
│       │       ├── auth/             #   Google ID token verifier + JWT issuer
│       │       ├── temporal/         #   Workflows + activities
│       │       ├── mpc/              #   gRPC client to sidecars
│       │       ├── solana/           #   RPC + escrow instruction builder + tx confirmation
│       │       ├── stripe/           #   Stripe payments + webhook verification
│       │       ├── razorpay/         #   Razorpay UPI disbursement
│       │       ├── fx/               #   ExchangeRate-API + Redis cache
│       │       └── sms/              #   Twilio + logging fallback
│       ├── test/                     # 65 unit test files
│       └── integration-test/         # 23 integration test files
├── programs/stablepay-escrow/        # Anchor program (Rust)
│   └── src/
│       ├── lib.rs                    # 4 instructions
│       ├── instructions/             # deposit, claim, refund, cancel
│       ├── state/                    # Escrow account + EscrowStatus enum
│       ├── errors.rs                 # 10 custom error codes
│       └── constants.rs              # PDA seeds
├── mpc-sidecar/                      # MPC threshold signing (Go)
│   ├── cmd/sidecar/                  # Entry point
│   ├── internal/
│   │   ├── tss/                      # DKG + Ed25519 signing
│   │   ├── p2p/                      # Ceremony registry + TCP coordination
│   │   ├── server/                   # gRPC (GenerateKey, Sign, HealthCheck)
│   │   └── config/                   # Environment-based config
│   └── proto/                        # Protobuf definitions (sidecar + p2p)
├── tests/                            # Anchor E2E tests (TypeScript, 799 lines)
├── docs/                             # Architecture, standards, ADRs
├── docker-compose.yml                # 8 services
├── Makefile                          # Build + orchestration
└── Anchor.toml

Contributing

All work goes through feature branches and pull requests. Never commit directly to main.

git checkout -b feature/STA-42-add-claim-page
cd backend && ./gradlew build
git push -u origin feature/STA-42-add-claim-page
gh pr create --title "STA-42: Add claim page"

Branch naming: feature/STA-{N}-description · Commit messages: feat(STA-{N}): description

About

Cross-border USD→INR remittance on Solana — Colosseum Frontier Hackathon

Resources

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors