Lewati ke konten utama

Advanced Usage 🚀

Level up your PAY.ID game! These are the pro patterns for production-grade integrations.

New to PAY.ID? Start with Simple Usage → first.


1️⃣ Complex Rule Patterns

Nested Rules (VIP vs Normal Paths) 🎯

Create different payment paths for different user types:

const VIP_RULE = {
id: 'vip_or_normal',
logic: 'OR' as const,
rules: [
{
// VIP path: high amount + KYC3 + low risk
id: 'vip_path',
logic: 'AND' as const,
rules: [
{ if: { field: 'tx.amount', op: '>=', value: '1000000000' } }, // >= 1000 USDC
{ if: { field: 'oracle.kycLevel', op: '==', value: '3' } },
{ if: { field: 'risk.score', op: '<=', value: '30' } },
],
},
{
// Normal path: 5-500 USDC + ID + KYC1 + moderate risk
id: 'normal_path',
logic: 'AND' as const,
rules: [
{ if: { field: 'tx.amount', op: 'between', value: ['5000000', '500000000'] } }, // 5-500 USDC
{ if: { field: 'oracle.country', op: '==', value: 'ID' } },
{ if: { field: 'oracle.kycLevel', op: '>=', value: '1' } },
{ if: { field: 'risk.score', op: '<=', value: '70' } },
],
},
],
}

What this does: VIP users can send big amounts (1000+ USDC) if they have KYC3 and low risk. Normal users have smaller limits (5-500 USDC) with relaxed requirements. Smart, right?


Time-Based Rules (Business Hours) ⏰

Only allow payments during work hours:

const BUSINESS_HOURS_RULE = {
id: 'business_hours',
if: {
field: 'env.timestamp',
op: 'between',
value: ['9:00', '17:00'],
transform: 'hour',
},
message: 'Payments only allowed during business hours (9 AM - 5 PM)',
}

Weekday-Only Rule 📅

No weekend payments:

const WEEKDAY_RULE = {
id: 'weekday_only',
if: {
field: 'env.timestamp',
op: 'in',
value: [1, 2, 3, 4, 5], // Mon-Fri
transform: 'day',
},
message: 'Payments only allowed on weekdays',
}

Cross-Field Validation (Daily Limit) 💰

Track spending across all payments:

const DAILY_LIMIT_RULE = {
id: 'daily_limit',
if: {
field: 'tx.amount',
op: '<=',
value: '$state.dailyLimit',
},
message: 'Exceeds daily spending limit',
}

2️⃣ Attestation-Gated Payments (EAS) 🔐

Require EAS attestations before payment — perfect for KYC requirements:

// Server-side with Context V2
import { buildContextV2 } from 'payid/context'

const contextV2 = await buildContextV2({
baseContext: {
tx: { sender: payer, receiver: merchant, asset: USDC, amount: '50000000', chainId: 1 },
},
oracle: {
issuer: oracleSigner,
data: {
kycLevel: '2',
attestationUID: '0xabc123...', // EAS attestation UID
},
},
})

// Rule checks for attestation
const ATTESTATION_RULE = {
id: 'kyc_attestation',
if: { field: 'oracle.attestationUID', op: 'exists' },
message: 'KYC attestation required',
}

// Payment requires attestationUIDs
await payid.evaluateAndProve({
context: contextV2,
authorityRule: ATTESTATION_RULE,
attestationUIDs: ['0xabc123...'],
// ... other params
})

On-chain verification happens automatically via PayIDVerifier.requireAllowed() — pretty cool, right?


3️⃣ Context V2 with Server-Signed Data 📝

Complete Context V2 Structure

Here's the full Context V2 structure with all the bells and whistles:

import { buildContextV2 } from 'payid/context'

const contextV2 = await buildContextV2({
baseContext: {
tx: { sender, receiver, asset, amount, chainId },
},
// Environment timestamp signed by server
env: {
issuer: envSigner,
data: { timestamp: Math.floor(Date.now() / 1000) },
},
// State tracking (spending limits)
state: {
issuer: stateSigner,
data: {
spentToday: '150000000', // 150 USDC spent today
dailyLimit: '500000000', // 500 USDC daily limit
period: 'DAY',
},
},
// External oracle data (KYC, country, etc.)
oracle: {
issuer: oracleSigner,
data: {
kycLevel: '2',
country: 'ID',
riskScore: '25',
},
},
// Risk scoring
risk: {
issuer: riskSigner,
data: {
score: 25,
category: 'LOW',
modelHash: '0x123...',
},
},
})

Trusted Issuers Setup 🔑

Tell PAY.ID which signers you trust:

const payid = createPayIDServer({
signer: serverSigner,
trustedIssuers: new Set([
'0xEnvSignerAddress...',
'0xStateSignerAddress...',
'0xOracleSignerAddress...',
'0xRiskSignerAddress...',
]),
})

4️⃣ Session Policies (QR Codes) 📱

Generate Session Policy

Create a payment policy that customers can scan:

import { createSessionPolicyV2, encodeSessionPolicyV2QR } from 'payid/sessionPolicy'

const policy = await createSessionPolicyV2({
receiver: merchantAddress,
ruleSetHash: activeRuleSetHash,
ruleAuthority: COMBINED_RULE_STORAGE,
allowedAsset: USDC_ADDRESS,
maxAmount: parseUnits('100', 6), // Max 100 USDC per scan
expiresAt: Math.floor(Date.now() / 1000) + 3600, // Valid 1 hour
payId: 'pay.id/my-shop',
chainId: 31337,
verifyingContract: PAYID_VERIFIER,
signer: merchantSigner,
})

const qrString = encodeSessionPolicyV2QR(policy)
// → "payid-v2:eyJ2ZXJzaW9uIjoicGF5aWQuc2Vzc2lvbi5wb2xpY3kudjIi..."

Decode and Use Session Policy

When someone scans your QR, decode it and use the policy:

import { decodeSessionPolicyV2QR } from 'payid/sessionPolicy'

const policy = decodeSessionPolicyV2QR(scannedQRString)

// Use with usePayIDFlow
execute({
receiver: policy.receiver as `0x${string}`,
asset: policy.allowedAsset as `0x${string}`,
amount: BigInt(policy.maxAmount),
payId: policy.payId,
sessionPolicyV2: policy, // Pass session policy
})

5️⃣ ERC-4337 Account Abstraction 🦊

PayID SDK is fully compatible with ERC-4337 Smart Accounts. The @/packages/sdk-core provides dedicated utility helpers under the erc4337 namespace to generate the required calldata and construct UserOperations.

Implementation Workflow

Step 1: Generate Decision Proof (Client/Server)

Use the PayID client/server to evaluate conditions and generate the off-chain decision proof.

import { createPayIDClient } from 'payid';

const sdk = createPayIDClient();
await sdk.ready;

const { result, proof } = await sdk.evaluateAndProve({
context,
authorityRule,
payId: "pay.id/username",
payer: smartAccountAddress, // Smart Account address
receiver,
asset,
amount,
signer,
verifyingContract: PAYID_VERIFIER_ADDRESS
});

Step 2: Build payNative/payERC20 CallData

Use the erc4337 namespace to build the encoded calldata for the PayWithPayID contract.

import { erc4337 } from 'payid';

let callData: string;

if (asset === '0x0000000000000000000000000000000000000000') {
// Native tokens (ETH, MATIC, A0GI, etc.)
callData = erc4337.buildPayNativeCallData(PAY_WITH_PAYID_ADDRESS, proof, []);
} else {
// ERC-20 tokens (USDC, USDT, etc.)
callData = erc4337.buildPayERC20CallData(PAY_WITH_PAYID_ADDRESS, proof, []);
}

Step 3: Construct UserOperation

Build the standard UserOperation object.

import { erc4337 } from 'payid';

const userOp = erc4337.buildUserOperation({
sender: smartAccountAddress,
callData: callData,
nonce: "0x...", // Retrieve from EntryPoint
gas: {
callGasLimit: "0x...",
verificationGasLimit: "0x...",
preVerificationGas: "0x...",
maxFeePerGas: "0x...",
maxPriorityFeePerGas: "0x..."
},
paymasterAndData: "0x..." // Optional: sponsor gas
});

Step 4: Sign and Submit to Bundler

Sign the user operation hash using the smart account owner's private key, then send it to the bundler RPC.

// 1. Sign UserOp Hash
const userOpHash = getUserOpHash(userOp, ENTRY_POINT_ADDRESS, chainId);
userOp.signature = await ownerWallet.signMessage(userOpHash);

// 2. Submit via eth_sendUserOperation RPC
await bundler.sendUserOperation(userOp, ENTRY_POINT_ADDRESS);

6️⃣ Custom State Tracking 📊

Track Spending Limits

Keep track of how much users spend on your server:

// Server maintains state
const userState = {
spentToday: 0n,
dailyLimit: 500_000_000n, // 500 USDC
period: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
}

// On each payment
userState.spentToday += amount

// Sign and include in context
const stateContext = await signState(userState, stateSigner)

const contextV2 = await buildContextV2({
baseContext: { tx: { /* ... */ } },
state: { issuer: stateSigner, data: userState },
})

Rule Enforces Limit

The rule checks the state and blocks overspending:

const DAILY_LIMIT_RULE = {
id: 'daily_limit',
logic: 'AND' as const,
rules: [
{ if: { field: 'state.spentToday', op: '<=', value: '$state.dailyLimit' } },
{ if: { field: 'state.period', op: '==', value: new Date().toISOString().slice(0, 10) } },
],
}

7️⃣ Risk Scoring Integration 🎯

Risk-Based Payment Limits

Adjust payment limits based on user risk score:

const RISK_BASED_RULE = {
id: 'risk_limits',
logic: 'OR' as const,
rules: [
{
// Low risk: up to 1000 USDC
id: 'low_risk',
logic: 'AND' as const,
rules: [
{ if: { field: 'risk.score', op: '<=', value: '30' } },
{ if: { field: 'tx.amount', op: '<=', value: '1000000000' } },
],
},
{
// Medium risk: up to 100 USDC
id: 'medium_risk',
logic: 'AND' as const,
rules: [
{ if: { field: 'risk.score', op: 'between', value: ['31', '70'] } },
{ if: { field: 'tx.amount', op: '<=', value: '100000000' } },
],
},
{
// High risk: up to 10 USDC
id: 'high_risk',
logic: 'AND' as const,
rules: [
{ if: { field: 'risk.score', op: '>', value: '70' } },
{ if: { field: 'tx.amount', op: '<=', value: '10000000' } },
],
},
],
}

8️⃣ Directional Rules (INBOUND/OUTBOUND) ↔️

Have different rules for receiving vs sending:

import { useActiveCombinedRuleByDirection } from 'payid-react'

// Get INBOUND rule (receiving payments)
const inboundPolicy = useActiveCombinedRuleByDirection(merchantAddress, 0)

// Get OUTBOUND rule (sending payments)
const outboundPolicy = useActiveCombinedRuleByDirection(userAddress, 1)

// Register directional rule
await registerCombinedRule({
ruleSetHash: keccak256(toBytes('inbound-policy')),
ruleNFTs: [ruleNFTAddress],
tokenIds: [1n],
version: 1n,
direction: 0, // INBOUND
})

9️⃣ DAO Payroll & Batch Payments 📦

PayWithPayIDBatch (Smart Contract)

The PayWithPayIDBatch contract lets you pay multiple recipients in a single transaction, saving gas and ensuring atomicity:

function batchPayETH(
PayIDVerifier.Decision[] calldata decisions,
bytes[] calldata sigs,
bytes32[][] calldata attestationUIDs
) external payable
import { usePayWithPayIDBatch } from './hooks/usePayWithPayIDBatch'

function BatchPayButton({
recipients,
decisions,
sigs,
attestationUIDs,
}: {
recipients: { address: `0x${string}`; amount: bigint }[]
decisions: any[]
sigs: `0x${string}`[]
attestationUIDs: `0x${string}`[][]
}) {
const { batchPayNative, isPending } = usePayWithPayIDBatch()

const totalValue = recipients.reduce((sum, r) => sum + r.amount, 0n)

const handleBatchPay = async () => {
await batchPayNative(decisions, sigs, attestationUIDs, totalValue)
}

return (
<button onClick={handleBatchPay} disabled={isPending}>
{isPending ? 'Batch Paying...' : `Pay ${recipients.length} Recipients`}
</button>
)
}

DAO Payroll UI

The frontend provides a full DAO Payroll page at /v4/app/payroll:

  • Contributor List — Add recipients with address, amount, role, and schedule (one-time / weekly / monthly)
  • Treasury Status — Live balance check with "insufficient funds" warning
  • Simulation — Pre-flight validation of addresses and balance before submitting
  • Batch Execution — One-time batch payment via PayWithPayIDBatch
  • Recurring Subscriptions — Create RecurringPayments subscriptions for scheduled payroll
import { useDAOPayroll } from '@/features/dao-payroll/hooks/useDAOPayroll'

function PayrollPage() {
const {
recipients,
addRecipient,
removeRecipient,
simulate,
createSubscriptions,
executeBatchPayment,
isCreating,
isBatching,
simulationResult,
totalPayroll,
isSufficient,
} = useDAOPayroll()

// Add a contributor
addRecipient() // uses form state: newAddress, newAmount, newRole, newSchedule

// Run simulation
simulate()

// Execute based on schedule type
if (schedule === 'one-time') {
await executeBatchPayment(decisions, sigs, attestationUIDs)
} else {
await createSubscriptions() // weekly / monthly via RecurringPayments
}
}

🔟 Time-Lock Vesting ⏳

Create token vesting schedules with cliff and linear release. Perfect for team allocations, investor locks, and contributor grants.

Smart Contract: TimeLockVesting

function createSchedule(
address beneficiary,
address asset,
uint256 totalAmount,
uint256 startTime,
uint256 cliff,
uint256 duration,
bool revocable,
address revoker
) external payable returns (uint256 scheduleId)

function release(uint256 scheduleId) external

function revoke(uint256 scheduleId) external

Frontend Usage

import { useTimeLockVesting } from '@/features/shared/hooks/useTimeLockVesting'

function VestingManager() {
const vesting = useTimeLockVesting()

// Create a 6-month cliff, 12-month total vesting schedule
const create = async () => {
const now = Math.floor(Date.now() / 1000)
const cliff = 180 * 24 * 60 * 60 // 180 days
const duration = 365 * 24 * 60 * 60 // 365 days

await vesting.createSchedule(
beneficiaryAddress,
'0x0000000000000000000000000000000000000000', // ETH
parseUnits('1000', 18), // 1000 ETH
BigInt(now),
BigInt(cliff),
BigInt(duration),
true, // revocable
revokerAddress,
parseUnits('1000', 18) // send full amount to contract
)
}

// Beneficiary releases vested tokens
const release = async (scheduleId: bigint) => {
await vesting.release(scheduleId)
}

// Revoker cancels remaining unvested tokens
const revoke = async (scheduleId: bigint) => {
await vesting.revoke(scheduleId)
}
}

Vesting Page UI

Route: /v4/app/vesting

  • Create Schedule — Set beneficiary, amount, cliff (months), duration (months), revocable flag
  • My Schedules — List of schedules where user is beneficiary or revoker
  • Progress Tracking — Visual progress bar showing cliff / vesting / fully vested status
  • Release Button — Available when vested > released
  • Revoke Button — Available for revoker on revocable schedules

1️⃣1️⃣ Multi-Chain Deployment 🌐

Configure Multiple Chains

Deploy across multiple networks:

import { hardhat, polygon, base, mainnet } from 'wagmi/chains'

const wagmiConfig = createConfig({
chains: [hardhat, polygon, base, mainnet],
transports: {
[hardhat.id]: http('http://127.0.0.1:8545'),
[polygon.id]: http(),
[base.id]: http(),
[mainnet.id]: http(),
},
})

const CONTRACT_ADDRESSES = {
[hardhat.id]: { /* localhost addresses */ },
[polygon.id]: { /* polygon addresses */ },
[base.id]: { /* base addresses */ },
[mainnet.id]: { /* mainnet addresses */ },
}

Chain-Specific Rules

Different chains, different rules:

const chainId = useChainId()

// Different rules per chain
const RULES_BY_CHAIN = {
1: ETHEREUM_RULES, // Mainnet rules
137: POLYGON_RULES, // Polygon rules
8453: BASE_RULES, // Base rules
31337: DEV_RULES, // Localhost rules
}

const activeRules = RULES_BY_CHAIN[chainId] || DEFAULT_RULES

1️⃣2️⃣ Error Handling & Retry Logic 🔄

Don't let network errors ruin the experience — retry automatically:

const { execute, status, error, reset } = usePayIDFlow()

const handlePayWithRetry = async () => {
let retries = 3
while (retries > 0) {
try {
await execute({ receiver, asset, amount, payId })
break // Success
} catch (e) {
retries--
if (retries === 0) throw e
await new Promise(r => setTimeout(r, 1000)) // Wait 1s
reset()
}
}
}

1️⃣3️⃣ Subscription Management 💳

Extend Rule Expiry

Keep your rules active by renewing:

import { useExtendRuleExpiry } from 'payid-react'

function RenewRule({ tokenId }: { tokenId: bigint }) {
const { extendRuleExpiry, isPending } = useExtendRuleExpiry()

const handleRenew = () => {
extendRuleExpiry({
tokenId,
newExpiry: BigInt(Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60), // +30 days
priceInWei: 100_000_000_000_000n, // 0.0001 ETH
})
}

return <button onClick={handleRenew} disabled={isPending}>Renew 30 Days</button>
}

Check Subscription Status

Show users their subscription info:

import { useSubscription } from 'payid-react'

function SubscriptionBadge() {
const { address } = useAccount()
const { data: sub } = useSubscription(address)

if (!sub?.isActive) {
return <span style={{ color: 'red' }}>⚠️ Subscription Expired</span>
}

const daysLeft = Math.floor((Number(sub.expiry) - Date.now() / 1000) / 86400)

return (
<span>
{sub.logicalRuleCount}/{sub.maxSlots} slots • {daysLeft} days left
</span>
)
}

1️⃣4️⃣ Custom IPFS Gateway 🌐

<PayIDProvider
contracts={CONTRACT_ADDRESSES}
ipfsGateway="https://my-custom-ipfs-gateway.com/ipfs/"
>
<App />
</PayIDProvider>

1️⃣5️⃣ Decision Proof Verification ✅

Verify a proof on-chain:

import { useVerifyDecision } from 'payid-react'

function VerifyProof({ decision, signature }: { decision: any, signature: string }) {
const { data: isValid, isLoading } = useVerifyDecision(decision, signature)

if (isLoading) return <p>Verifying...</p>

return <p>{isValid ? '✅ Valid Proof' : '❌ Invalid Proof'}</p>
}

1️⃣6️⃣ Nonce Management 🔒

Check if a nonce was already used (prevents replay attacks):

import { useNonceUsed } from 'payid-react'

function CheckNonce({ payer, nonce }: { payer: `0x${string}`, nonce: string }) {
const { data: used } = useNonceUsed(payer, nonce)

if (used) {
return <p style={{ color: 'red' }}>⚠️ Nonce already used - possible replay attack</p>
}

return <p>✅ Nonce is fresh</p>
}

1️⃣6️⃣ Plug-and-Play Adapters 🔌

PAY.ID supports custom reputation and escrow adapters, allowing platforms with their own on-chain systems (e.g. any platform's milestone manager + reputation contract) to integrate seamlessly without duplicating features.

How It Works

ComponentPAY.ID DefaultPlatform Override
ReputationVindexRegistryIReputationAdapter
EscrowEscrowMilestoneIEscrowAdapter

When an adapter is injected, PAY.ID hooks automatically route to it. When omitted, hooks fall back to native contract calls. Pass a noop adapter to completely disable a feature.

Inject Custom Adapters

import {
PayIDProvider,
NoopReputationAdapter, // disable PAY.ID reputation
NoopEscrowAdapter, // disable PAY.ID escrow
} from 'payid-react';

// Platform with its own reputation + escrow
function YourApp() {
return (
<PayIDProvider
contracts={YOUR_PAYID_CONTRACTS}
reputationAdapter={NoopReputationAdapter} // hide VRAN UI
escrowAdapter={NoopEscrowAdapter} // hide Escrow UI
>
<App />
</PayIDProvider>
);
}

Build Your Own Adapter

import type { IReputationAdapter, ReputationResult } from 'payid-react';

class YourPlatformReputationAdapter implements IReputationAdapter {
readonly name = 'your-platform';
readonly label = 'Your Platform Reputation';

async getReputation(target: `0x${string}`): Promise<ReputationResult> {
// Call your platform's reputation contract
const score = await platform.reputation.getScore(target);
return { score, isBlacklisted: score < 100, isTrusted: score >= 700 };
}

async getConfig() {
return { minStake: parseEther('0.01'), consensusThreshold: 3n, minReporterReputation: 700n };
}

async canReport(address: `0x${string}`) {
const score = await platform.reputation.getScore(address);
return score >= 100;
}
}

// Use it
<PayIDProvider reputationAdapter={new YourPlatformReputationAdapter()}>
<App />
</PayIDProvider>

Feature Flags in UI

import { usePayIDContext } from 'payid-react';

function AppLayout() {
const { features } = usePayIDContext();

return (
<nav>
{features.reputation && <Link to="/reputation">Reputation</Link>}
{features.escrow && <Link to="/escrow">Escrow</Link>}
{/* Always show core features */}
<Link to="/send">Send</Link>
<Link to="/receive">Receive</Link>
</nav>
);
}

Key principle: PAY.ID core (rules, payments, proofs) is always active. Optional modules (reputation, escrow) are opt-in via adapter injection.


Advanced Patterns Summary 📚

PatternUse Case
Nested RulesVIP vs normal payment paths
Time-Based RulesBusiness hours, weekday-only
Attestation-GatedKYC-required payments
Context V2Server-signed oracle data
Session PoliciesQR code payments
ERC-4337Account abstraction
State TrackingSpending limits
Risk ScoringDynamic limits based on risk
Directional RulesSeparate INBOUND/OUTBOUND policies
DAO PayrollBatch + recurring contributor payments
Time-Lock VestingCliff + linear release schedules
Plug-and-Play AdaptersIntegrate platform-specific reputation/escrow
Multi-ChainDeploy across networks
Retry LogicHandle transient failures
SubscriptionRule slot management

Next Steps 🎯


You're now a PAY.ID pro! 🎉