Overview
The identiPay payment flow consists of three main phases:
Proposal : Merchant creates a payment intent
Intent : Buyer reviews and signs the intent
Settlement : Atomic on-chain execution with ZK proofs
Each phase maintains privacy while ensuring cryptographic verifiability.
Payment Lifecycle
Phase 1: Proposal Creation
The merchant creates a CommerceProposal containing all transaction details.
Creating a Proposal
POST / api / identipay / v1 / proposals
Authorization : Bearer idpay_sk_ ...
{
"items" : [
{
"name" : "Studio Monitor Headphones" ,
"quantity" : 1 ,
"unitPrice" : "0.25" ,
"currency" : "USDC"
},
{
"name" : "Mechanical Keyboard" ,
"quantity" : 1 ,
"unitPrice" : "0.18" ,
"currency" : "USDC"
}
],
"amount" : {
"value" : "0.43" ,
"currency" : "USDC"
},
"deliverables" : {
"receipt" : true ,
"warranty" : {
"durationDays" : 365 ,
"transferable" : true
}
},
"constraints" : {
"ageGate" : 18
},
"expiresInSeconds" : 900
}
Proposal Generation
The backend:
Validates the request against the merchant’s API key
Generates a unique transactionId (UUID)
Builds the full JSON-LD CommerceProposal:
{
"@context" : "https://schema.identipay.net/v1" ,
"@type" : "CommerceProposal" ,
"transactionId" : "f47ac10b-58cc-4372-a567-0e02b2c3d479" ,
"merchant" : {
"did" : "did:identipay:techvault.store:ccd20e4a-ae80-49f3-862c-2f05c2714d1b" ,
"name" : "TechVault" ,
"suiAddress" : "0x9f9a52525712f64c6225f076857cb5c32096c203a499760f43749ee360d4a5fa" ,
"publicKey" : "a6317b7521f98e96e8ac16dab916af8fdc3f65be3e7305954f219f6ca64dcdb5"
},
"items" : [ ... ],
"amount" : { "value" : "0.43" , "currency" : "USDC" },
"deliverables" : { ... },
"constraints" : { "ageGate" : 18 },
"expiresAt" : "2026-03-09T15:30:00.000Z" ,
"intentHash" : "a3d5f8e9c2b1a4d6f8e9c2b1a4d6f8e9c2b1a4d6f8e9c2b1a4d6f8e9c2b1a4d6" ,
"settlementChain" : "sui" ,
"settlementModule" : "0xPackageId::settlement"
}
Computes the intentHash (cryptographic hash of all fields)
Generates QR code containing the DID URI
Stores proposal in database with status pending
Intent Hash
The intent hash is a SHA-256 hash of the canonical JSON representation:
// From backend source: src/services/proposal.service.ts
import { sha256 } from "@noble/hashes/sha256" ;
import { bytesToHex } from "@noble/hashes/utils" ;
function computeIntentHash ( proposal : CommerceProposal ) : string {
// Create canonical representation (deterministic field order)
const canonical = JSON . stringify ({
transactionId: proposal . transactionId ,
merchant: proposal . merchant ,
items: proposal . items ,
amount: proposal . amount ,
deliverables: proposal . deliverables ,
constraints: proposal . constraints ,
expiresAt: proposal . expiresAt ,
settlementChain: proposal . settlementChain ,
settlementModule: proposal . settlementModule ,
});
const hash = sha256 ( new TextEncoder (). encode ( canonical ));
return bytesToHex ( hash );
}
This hash is signed by the buyer and verified on-chain.
Database State
INSERT INTO proposals (
transaction_id,
merchant_id,
proposal_json,
intent_hash,
status ,
expires_at
) VALUES (
'f47ac10b-58cc-4372-a567-0e02b2c3d479' ,
'ccd20e4a-ae80-49f3-862c-2f05c2714d1b' ,
'{...}' , -- Full JSON proposal
'a3d5f8e9...' ,
'pending' ,
'2026-03-09 15:30:00'
);
Phase 2: Intent Resolution
The buyer scans the QR code and the wallet fetches proposal details.
Wallet QR Scan
Scan QR code : Contains did:identipay:techvault.store:f47ac10b-58cc-4372-a567-0e02b2c3d479
Parse DID : Extract hostname (techvault.store) and transaction ID
Fetch proposal :
GET https : //techvault.store/api/identipay/v1/intents/f47ac10b-58cc-4372-a567-0e02b2c3d479
This endpoint is public and does not require authentication. The wallet needs to fetch proposal details before the buyer has authenticated.
Backend Intent Handler
From backend/src/routes/intents.ts:10-42:
app . get ( "/:txId" , async ( c ) => {
const txId = c . req . param ( "txId" );
const [ proposal ] = await deps . db
. select ()
. from ( proposals )
. where ( eq ( proposals . transactionId , txId ))
. limit ( 1 );
if ( ! proposal ) {
throw new NotFoundError ( "Proposal not found" );
}
// Check expiry
if ( new Date ( proposal . expiresAt ) < new Date ()) {
if ( proposal . status === "pending" ) {
await deps . db
. update ( proposals )
. set ({ status: "expired" })
. where ( eq ( proposals . transactionId , txId ));
}
throw new ValidationError ( "Proposal has expired" );
}
if ( proposal . status === "cancelled" ) {
throw new ValidationError ( "Proposal has been cancelled" );
}
return c . json ( proposal . proposalJson );
});
Wallet Verification
Before showing the payment to the user, the wallet:
Verifies merchant DID against on-chain trust registry
Validates intent hash matches proposal content
Checks expiration time
Displays merchant name, items, amount to user
User Review & Approval
The wallet presents:
Pay 0.43 USDC to TechVault
Items:
• Studio Monitor Headphones (1x) - 0.25 USDC
• Mechanical Keyboard (1x) - 0.18 USDC
Age verification required: 18+
Your age will be verified using zero-knowledge proof.
Your birthdate will not be shared.
You will receive:
✓ Encrypted receipt
✓ 1-year transferable warranty
[Cancel] [Approve Payment]
Phase 3: Settlement
Once the buyer approves, the wallet constructs and submits the settlement transaction.
Pre-Settlement: ZK Proof Generation
If age verification is required:
// Wallet generates ZK proof
const proof = await generateAgeProof ({
birthYear: userBirthYear , // Private input
currentYear: 2026 , // Public input
requiredAge: 18 , // Public input
identityCommitment: commitment , // Public input
});
// Proof output:
{
proof : Uint8Array ( 256 ), // zk-SNARK proof
publicInputs : [
currentYear - requiredAge , // 2008 (threshold year)
identityCommitment , // User's identity commitment
]
}
The proof proves: birthYear <= (currentYear - requiredAge) without revealing birthYear .
Stealth Address Generation
The wallet generates a one-time stealth address for receipt delivery:
// From buyer's meta-address (spendPubkey, viewPubkey)
const ephemeralKey = randomBytes ( 32 );
const stealthAddress = deriveStealthAddress (
merchantPublicKey ,
buyerViewKey ,
buyerSpendKey ,
ephemeralKey
);
const viewTag = computeViewTag ( sharedSecret ); // First 4 bytes
This ensures:
Merchant cannot link this payment to buyer’s identity
Only buyer can decrypt receipt using their view key
No address reuse (every payment uses unique address)
Settlement Transaction Construction
The wallet builds a sponsored transaction:
POST / api / identipay / v1 / transactions / gas - sponsor
{
"type" : "settlement" ,
"senderAddress" : "0xBuyerAddress" ,
"coinType" : "0x2::sui::SUI" ,
"amount" : "430000" ,
"merchantAddress" : "0x9f9a52525712f64c6225f076857cb5c32096c203a499760f43749ee360d4a5fa" ,
"buyerStealthAddr" : "0xGeneratedStealthAddress" ,
"intentSig" : [ /* buyer's signature of intentHash */ ],
"intentHash" : [ /* 32 bytes */ ],
"buyerPubkey" : [ /* 32 bytes */ ],
"proposalExpiry" : "1709999400000" ,
"encryptedPayload" : [ /* encrypted receipt data */ ],
"payloadNonce" : [ /* 24 bytes */ ],
"ephemeralPubkey" : [ /* 32 bytes for stealth */ ],
"encryptedWarrantyTerms" : [ /* encrypted warranty */ ],
"warrantyTermsNonce" : [ /* 24 bytes */ ],
"warrantyExpiry" : "1741535400000" ,
"warrantyTransferable" : true ,
"stealthEphemeralPubkey" : [ /* 32 bytes */ ],
"stealthViewTag" : 42 ,
"zkProof" : [ /* 256 bytes zk-SNARK */ ],
"zkPublicInputs" : [ /* public inputs */ ]
}
From backend/src/routes/transactions.ts:43-70:
if ( body . type === "settlement" || body . type === "settlement_no_zk" ) {
const params = body as Record < string , unknown >;
if ( ! params . amount || ! params . merchantAddress ) {
throw new ValidationError ( "Missing required settlement parameters" );
}
txBytes = await deps . suiService . buildSponsoredSettlement ({
senderAddress: params . senderAddress as string ,
coinId: params . coinId as string | undefined ,
coinType: params . coinType as string ,
amount: String ( params . amount ),
merchantAddress: params . merchantAddress as string ,
buyerStealthAddr: params . buyerStealthAddr as string ,
intentSig: params . intentSig as number [],
intentHash: params . intentHash as number [],
buyerPubkey: params . buyerPubkey as number [],
proposalExpiry: String ( params . proposalExpiry ),
encryptedPayload: params . encryptedPayload as number [],
payloadNonce: params . payloadNonce as number [],
ephemeralPubkey: params . ephemeralPubkey as number [],
// ... warranty and ZK fields
});
}
Backend returns unsigned transaction bytes. Wallet signs and submits:
POST / api / identipay / v1 / transactions / submit
{
"txBytes" : "base64EncodedTransactionBytes" ,
"senderSignature" : "base64EncodedSignature"
}
Backend co-signs as gas sponsor and submits to Sui network.
On-Chain Verification
The Sui Move smart contract verifies:
Intent signature : Buyer actually signed the intentHash
Merchant identity : DID matches trust registry
Expiration : Current time < proposal expiry
ZK proof (if required): Age constraint satisfied
Amount : Matches proposal exactly
If all checks pass:
Transfer USDC from buyer to merchant
Emit StealthAnnouncement event with stealth address and ephemeral key
Mint encrypted receipt NFT to buyer’s stealth address
Mint warranty NFT (if applicable)
Settlement Event
On-chain event emitted:
event StealthAnnouncement {
stealth_address : address ,
ephemeral_pubkey : vector < u8 >,
view_tag : u8 ,
tx_digest : vector < u8 >,
encrypted_payload : vector < u8 >,
encrypted_warranty : vector < u8 >,
}
The backend:
Listens for StealthAnnouncement events
Updates proposal status to settled
Stores Sui transaction digest
Pushes WebSocket notification to merchant
// From backend/src/ws/status.ts:81-103
export function pushSettlementUpdate (
txId : string ,
status : string ,
suiTxDigest : string ,
) {
const set = connections . get ( txId );
if ( ! set ) return ;
const message = JSON . stringify ({
type: "settlement" ,
transactionId: txId ,
status ,
suiTxDigest ,
});
for ( const ws of set ) {
try {
ws . send ( message );
} catch {
set . delete ( ws );
}
}
}
State Transitions
Proposal Status Flow
pending
Initial state after proposal creation. Waiting for buyer to scan and pay.
settled
Payment completed successfully. Transaction confirmed on Sui blockchain.
expired
Proposal exceeded expiresAt timestamp without settlement.
cancelled
Merchant cancelled the proposal before settlement.
Database Schema
From backend/src/db/schema.ts:15-20:
export const proposalStatusEnum = pgEnum ( "proposal_status" , [
"pending" ,
"settled" ,
"expired" ,
"cancelled" ,
]);
Status Transitions
pending → settled // Payment successful
pending → expired // Timeout
pending → cancelled // Merchant cancellation
// Terminal states (no further transitions):
settled , expired , cancelled
Privacy Guarantees
Throughout the payment flow:
Merchant Privacy
Public : DID, name, Sui address (from trust registry)
Private : API key, customer list, sales analytics
Buyer Privacy
Public : Nothing (completely anonymous)
Private : Identity, age, address, purchase history
On-chain : Only stealth address (not linkable to buyer)
Receipt Privacy
Encrypted with buyer’s public key
Delivered to stealth address
Only buyer can decrypt with view key
Merchant cannot read receipt or link to buyer
Zero-Knowledge Proofs
Age verification : Proves age >= threshold without revealing birthdate
Identity commitment : Proves ownership without revealing identity
No personal data revealed to merchant or blockchain
Error Handling
Common Errors
Transaction ID does not exist or has been deleted
Proposal exceeded expiration time
Merchant cancelled the proposal
Buyer’s signature does not match intent hash
ZKProofVerificationFailed
Zero-knowledge proof is invalid or does not satisfy constraints
Buyer’s wallet has insufficient balance
Merchant DID not found in on-chain trust registry
Retry Logic
If settlement transaction fails:
Wallet retries up to 3 times with exponential backoff
Backend re-sponsors transaction if gas price changed
After 3 failures : Show error to user, proposal remains pending
User can retry manually within expiration window
Testing
Test the complete flow:
// 1. Create proposal
const proposal = await createProposal ({
items: [{ name: "Test Item" , quantity: 1 , unitPrice: "1.00" }],
amount: { value: "1.00" , currency: "USDC" },
deliverables: { receipt: true },
expiresInSeconds: 900 ,
});
// 2. Simulate wallet scan
const intent = await fetch (
`https://api.identipay.net/api/identipay/v1/intents/ ${ proposal . transactionId } `
). then ( r => r . json ());
// 3. Check settlement status
const status = await fetch (
`https://api.identipay.net/api/identipay/v1/transactions/ ${ proposal . transactionId } /status` ,
{ headers: { Authorization: `Bearer ${ apiKey } ` } }
). then ( r => r . json ());
Next Steps
WebSocket API Receive real-time settlement notifications