Alternative pay-in path: instead of routing through Payment Flow - SHKeeper, the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an on-chain transfer to the escrow wallet directly. The backend then verifies the transaction against the BSC RPC.
Actors
Buyer — owner of the wallet doing the on-chain transfer.
Frontend — frontend/src/web3/ (wagmi provider, web3-provider context); wallet-connect UI in frontend/src/sections/account/account-wallet-connection.tsx; the in-checkout integration is in frontend/src/sections/request/components/buyer-steps/payment-card.tsx and frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx.
Blockchain (BSC) — verified via https://bsc-dataseed.binance.org/ JSON-RPC.
MongoDB — payments collection (same model as SHKeeper, different provider value).
Socket.IO — payment-created, plus the cascade events from Payment Flow - SHKeeper when verification succeeds.
Preconditions
NEXT_PUBLIC_ESCROW_WALLET_ADDRESS is set on the frontend (see frontend/src/global-config.ts). This is the destination address — typically a custodial BSC wallet operated by the platform admin.
Wagmi is configured with WalletConnect projectID and supported chains (BSC mainnet 56, optionally BSC testnet 97).
Buyer has a wallet with USDT/USDC balance and a small amount of BNB for gas.
Step-by-step narrative
Phase 1 — Connect wallet
Buyer hits the payment step and sees both pay options. Clicking "Pay with wallet" opens the WalletConnect modal via wagmi's useConnect().
The connection emits an accountsChanged event; the web3 context (frontend/src/web3/context/web3-provider.tsx) stores wallet.address and wallet.chainId.
If chainId !== 56 (BSC), the UI prompts a wallet_switchEthereumChain request.
Phase 2 — Create intent on backend
Frontend POSTs POST /api/payment/decentralized/save with { purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }. The backend records a Payment with provider: 'other' (or 'decentralized' depending on enum extension), direction: 'in', status: 'pending', blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}. Auth: Bearer JWT required.
Response includes the escrow wallet address and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper convertPaymentAmountForShkeeper is shared from currencyUtils.ts).
Phase 3 — Token approval (ERC-20 / BEP-20)
The frontend checks the user's current allowance via useReadContract / allowance(owner, spender) on the USDT contract.
If allowance < amount, the frontend prompts an approve(escrow, amount) transaction. After confirmation, it proceeds to step 8.
Some flows skip approve and use direct transfer instead (transfer(to, amount) — no approval needed because the buyer is the holder, not a contract pulling from them). The codebase favours direct transfer; see usages of web3Service.transferToken(...) in frontend/src/web3/web3Service.ts.
Phase 4 — On-chain transfer
Frontend calls transfer(escrowAddress, amount) on the USDT contract via wagmi's useWriteContract. The wallet popup asks the user to confirm gas.
The buyer signs; the transaction is broadcast.
Frontend listens via wagmi's useWaitForTransactionReceipt({ hash }). On success (1 confirmation), it captures the transactionHash.
Phase 5 — Backend verification
Frontend POSTs POST /api/payment/decentralized/verify/:paymentId with { transactionHash }. Auth: Bearer JWT required (owner or admin).
JSON-RPC eth_getTransactionReceipt against bsc-dataseed.binance.org.
Confirms receipt.status === '0x1' (success).
Computes confirmations = current eth_blockNumber - receipt.blockNumber.
Optionally decodes the Transfer event log to confirm from, to, and value match the expected payment.
On success the backend:
Updates the Payment: status = 'completed', escrowState = 'funded', blockchain.transactionHash, blockchain.confirmations, blockchain.confirmedAt = now.
Triggers the same cascade as the SHKeeper webhook: mark winning offer accepted, reject others, transition request to payment, create chat, send notifications, emit socket events.
payment-created (admin dashboard) on intent creation.
seller-offer-update'payment-completed' and 'offer-rejected' post-verification.
purchase-request-update'status-changed'.
new-notification to both parties.
Side effects
No SHKeeper involvement — the escrow wallet is custodial; the platform admin holds the keys. Payouts from this wallet to sellers happen via Payout Flow (SHKeeper payouts API) or manual admin signing using admin-wallet-payout.tsx UI.
Verified-wallet field — the buyer's connected wallet is also saved against User.profile.walletAddress (in WalletConnectionCard), which is later used to refund this same wallet if the order is disputed.
Error / edge cases
Wrong network → frontend forces a chain switch; if user refuses, transaction will fail or hit the wrong chain (escrow is BSC-only today).
Insufficient gas (BNB) → wallet rejects the tx; nothing to verify.
Transaction reverted (receipt.status === '0x0') → verifier returns failed; backend marks Payment.status = 'failed'. Buyer can retry.
Transaction not yet mined at verification time → verifier returns pending with error: 'Transaction not found or still pending'. Frontend retries verification on a backoff schedule.
Tx hash already used by another payment — blockchain.transactionHash has a sparse index (Payment.ts:178); a duplicate verification attempt finds the existing payment and returns its status.
Wrong amount or wrong recipient — must be enforced by log decoding (the verifier should reject if the Transfer event's value is less than expected or to ≠ escrow). The current verifier only checks the receipt's status; tightening this is recommended.
RPC throttling — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
User closes the browser before verification — the on-chain transfer still happened. A periodic reconciliation job (/api/payment/fetch-tx/:paymentId) or admin tool can replay verification from the txHash.
Confirmation depth — currently 1 confirmation triggers completed. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
[!warning] Verify the event log, not just the receipt
A receipt status of 0x1 means the transaction did not revert — it does not confirm the right amount went to the right address. Decode the ERC-20 Transfer(address,address,uint256) event and assert to == ESCROW_WALLET_ADDRESS and value >= expectedAmount. The current implementation in decentralizedPaymentService.ts checks only the receipt status; harden this before accepting large payments.