End-to-end payment flow for privacy-preserving merchant checkout
identiPay enables privacy-preserving merchant payments using stealth addresses, encrypted receipts, and optional age verification. Merchants create payment proposals, users scan QR codes, and transactions settle on-chain with zero-knowledge proofs.
The merchant payment flow consists of three main phases: proposal creation, customer checkout, and on-chain settlement.
1
Merchant creates payment proposal
The merchant generates a payment request with items, amount, and optional constraints (like age requirements). The backend computes an intent hash that binds all payment parameters.
2
Customer scans QR code
The wallet app scans the merchant’s QR code, fetches the proposal, and independently verifies the intent hash to prevent tampering.
3
Wallet executes checkout
The wallet derives a one-time stealth address for receipt delivery, signs the intent, encrypts the receipt for the merchant, and submits the settlement transaction.
4
Transaction settles on-chain
The settlement contract verifies signatures and ZK proofs (if age-gated), transfers USDC to the merchant, and emits an announcement event with the encrypted receipt.
The intent hash is computed using Poseidon and binds the merchant DID, amount, expiration, age threshold, and settlement module address. This prevents tampering and replay attacks.
The wallet’s scanner screen detects identiPay QR codes and extracts the transaction ID:
// From ScannerViewModel.ktprivate val IDENTIPAY_PAY_REGEX = Regex("""identipay://pay/([a-f0-9-]+)""")private val IDENTIPAY_URL_REGEX = Regex("""https?://identipay\\.me/pay/([a-f0-9-]+)""")private val IDENTIPAY_DID_REGEX = Regex("""did:identipay:[^:]+:([a-f0-9-]+)""")
The wallet fetches the proposal from the backend and independently verifies the intent hash:
// From CommerceRepository.kt:62-76val proposal = backendApi.getIntent(txId)// Independently verify the intent hashif (!intentHashComputer.verify(proposal)) { return Result.failure( IllegalStateException("Intent hash verification failed — proposal may be tampered") )}
The wallet never trusts the backend’s intent hash directly. It recomputes the hash from scratch using the same Poseidon circuit logic to prevent man-in-the-middle attacks.
When the user confirms payment, the wallet orchestrates a multi-step checkout flow.
1
Derive buyer stealth address
The wallet generates a one-time stealth address for receipt delivery. This prevents linking the buyer’s public identity to the transaction.
2
Generate ZK age proof (if required)
If the merchant requires age verification (e.g., ageGate: 21), the wallet generates a zero-knowledge proof that the user is at least 21 years old without revealing their exact birth date.
3
Encrypt receipt and warranty
The wallet encrypts the receipt JSON using ECDH with the merchant’s public key. The merchant can later decrypt it to fulfill the order.
4
Sign and submit settlement
The wallet signs the intent hash with the stealth address private key and submits the transaction via gas sponsorship.
The wallet uses gas sponsorship to submit the settlement transaction. The backend builds the programmable transaction block (PTB), the wallet signs with the stealth private key, and the backend co-signs as the gas payer:
// From CommerceRepository.kt:312-353val sponsorResponse = backendApi.sponsorSettlement( GasSponsorSettlementRequest( type = if (isAgeGated) "settlement" else "settlement_no_zk", senderAddress = source.stealthAddress, coinType = USDC_TYPE, amount = amountMicros.toString(), merchantAddress = proposal.merchant.suiAddress, buyerStealthAddr = scanResult.stealthAddress, intentSig = intentSig.map { it.toInt() and 0xFF }, intentHash = intentHashBytes.map { it.toInt() and 0xFF }, buyerPubkey = buyerPubkey.map { it.toInt() and 0xFF }, proposalExpiry = proposalExpiry.toString(), encryptedPayload = encryptedReceipt.ciphertext.map { it.toInt() and 0xFF }, payloadNonce = encryptedReceipt.nonce.map { it.toInt() and 0xFF }, ephemeralPubkey = encryptedReceipt.ephemeralPubkey.map { it.toInt() and 0xFF }, encryptedWarrantyTerms = encryptedWarranty?.ciphertext?.map { it.toInt() and 0xFF } ?: emptyList(), warrantyTermsNonce = encryptedWarranty?.nonce?.map { it.toInt() and 0xFF } ?: emptyList(), warrantyExpiry = warrantyExpiry.toString(), warrantyTransferable = warrantyTransferable, stealthEphemeralPubkey = buyerStealth.ephemeralPubkey.map { it.toInt() and 0xFF }, stealthViewTag = buyerStealth.viewTag, zkProof = zkProof?.map { it.toInt() and 0xFF }, zkPublicInputs = zkPublicInputs?.map { it.toInt() and 0xFF }, ))// Sign with stealth private keyval signature = signTransaction(sponsorResponse.txBytes, senderPrivKey)// Submit via backend (backend co-signs as gas owner)val submitResponse = backendApi.submitSponsoredTx( SubmitTxRequest( txBytes = sponsorResponse.txBytes, senderSignature = signature, ))
Gas Sponsorship
The backend pays transaction gas fees, enabling seamless UX without requiring users to hold SUI tokens.
Dual Signatures
The wallet signs as the sender (transferring USDC), and the backend co-signs as the gas payer.