Skip to content

RFC-010: Nexus Escrow Smart Contract Specification

MetadataValue
TitleNexus Escrow Smart Contract Specification
Version3.0.0
StatusStandards Track (Draft)
AuthorsCipher & Nexus Architect Team
Created2026-02-24
Updated2026-04-01
DependenciesRFC-005v4 (Payment Core), RFC-009 (Webhook Standard), RFC-001 (DID)
Target ChainPlatON Devnet (chain_id: 20250407)
Payment TokensERC-3009 compatible tokens (XSGD, USDC)
Contract StandardsERC-20, EIP-3009, EIP-712, OpenZeppelin v5, UUPS Upgradeable
Gas ModelRelayer-sponsored (zero Gas for users)

1. Abstract

This RFC defines the complete specification for Nexus Escrow smart contract v4.3.0. The contract acts as a payment escrow, implementing a gasless escrow transaction flow: "User signs -> Relayer sponsors deposit -> Merchant fulfills -> Funds released."

v3.0.0 key changes (relative to RFC v2.0.0):

  1. UUPS Proxy: Adopts an upgradeable proxy pattern, enabling on-chain contract logic upgrades
  2. Gasless Batch Deposit: batchDepositGasless() -- Relayer submits batch deposits on behalf of users, zero Gas for users
  3. Group Signature (Anti-MITM): EIP-712 signature verification ensuring batch transaction parameter integrity
  4. Cancel Mechanism: cancelByOperator() -- Operator/merchant instant refund without waiting for timeout
  5. Auto-Refund Unresolved Dispute: refundUnresolvedDispute() -- Automatic refund after arbitration timeout
  6. Multi-Token: setToken() -- Supports switching between ERC-3009 compatible tokens
  7. Fee Snapshot: Snapshots the fee rate at deposit time, preventing subsequent fee changes from affecting existing escrows
  8. MAX_BATCH_SIZE: Maximum 20 entries per batch, preventing Gas griefing

2. Motivation

2.1 Escrow vs Direct Transfer

Direct Transfer (original model)          Escrow Contract (this RFC)
---------------------                     ----------------------
User --transfer--> Merchant               User --sign--> Relayer --deposit--> Contract --release--> Merchant
                                                                                 |
                                                                            timeout? --refund--> User
                                                                            dispute? --arbitrate--> ruling
                                                                            cancel? --cancelByOperator--> instant refund

2.2 Multi-Dimensional Comparison

DimensionDirect TransferEscrow ContractAssessment
SecurityIrreversible, no recourseTimeout refund + arbitration + instant cancelEscrow wins
On-chain DataTransfer(from, to, value) -- 3 fieldsCustom events with paymentId, orderRef, merchantDidEscrow wins
Gas Cost~65K (user pays)~220K (Relayer pays, zero Gas for user)Escrow wins (UX)
Dispute HandlingEntirely off-chainOn-chain arbitration + auto-refundEscrow wins
Cross-chain CompatibilityNo calldataContract address + calldata for bridge integrationEscrow wins

3. Contract Architecture

3.1 UUPS Proxy Pattern

┌─────────────────┐      ┌──────────────────────────────┐
│  ERC1967 Proxy   │ ──►  │  NexusEscrow Impl v4.3.0  │
│  (stable address)│      │  (upgradeable logic)          │
│  0xeB33a9C2...   │      │  0xF6ED311f...                │
└─────────────────┘      └──────────────────────────────┘
  • The proxy address is permanent; all interactions go through the proxy
  • Upgrades are performed via _authorizeUpgrade() (owner only)
  • Storage layout resides in the proxy; logic resides in the implementation

3.2 Contract Inheritance

solidity
contract NexusEscrow is
    Initializable,
    UUPSUpgradeable,
    OwnableUpgradeable,
    ReentrancyGuardUpgradeable

4. State Machine

4.1 Status Enum

solidity
enum EscrowStatus {
    NONE,                  // 0: Does not exist
    DEPOSITED,             // 1: Funds locked, awaiting merchant fulfillment
    RELEASED,              // 2: Merchant has withdrawn (fee deducted)
    REFUNDED,              // 3: Refunded (timeout / cancel / arbitration timeout)
    DISPUTED,              // 4: Under dispute, awaiting arbitration
    RESOLVED_TO_MERCHANT,  // 5: Arbitration ruled in favor of merchant
    RESOLVED_TO_PAYER,     // 6: Arbitration ruled in favor of payer
    RESOLVED_SPLIT         // 7: Arbitration split proportionally (new in v4.0.0)
}

4.2 State Transition Diagram

                    ┌──────────────────┐
                    │     DEPOSITED    │ <-- deposit / batchDepositGasless
                    └────────┬─────────┘

              ┌──────────────┼──────────────┬───────────────┐
              │              │              │               │
     ┌────────▼───────┐  ┌──▼──────────┐  ┌▼──────────┐  ┌▼───────────────┐
     │   RELEASED     │  │  REFUNDED   │  │ REFUNDED   │  │   DISPUTED     │
     │  (release)     │  │ (timeout    │  │(cancelBy   │  │  (under        │
     │                │  │  refund)    │  │ Operator)  │  │   dispute)     │
     └────────────────┘  └─────────────┘  └────────────┘  └───────┬────────┘

                                                    ┌──────────────┼──────────────────┐
                                                    │              │                  │
                                             ┌──────▼──────┐ ┌────▼────────┐  ┌──────▼─────────┐
                                             │ RESOLVED_    │ │ RESOLVED_   │  │ RESOLVED_      │
                                             │ TO_MERCHANT  │ │ SPLIT       │  │ TO_PAYER       │
                                             │ (>=50% to    │ │ (proportion-│  │ (<50% to       │
                                             │  merchant)   │ │  al split)  │  │  merchant)     │
                                             └─────────────┘ └─────────────┘  └────────────────┘


                                                                            refundUnresolvedDispute
                                                                            (auto-refund on
                                                                             arbitration timeout)

4.3 Transition Rules

Current StatusTarget StatusTrigger ConditionCaller
(none)DEPOSITEDdeposit / batchDepositGaslessRelayer / User
DEPOSITEDRELEASEDrelease()Core operator / merchant
DEPOSITEDREFUNDEDrefund() (after timeout)Anyone (public)
DEPOSITEDREFUNDEDcancelByOperator() (instant)Core operator / merchant
DEPOSITEDDISPUTEDdispute() (within dispute window)Payer
DISPUTEDRESOLVED_TO_MERCHANTresolve(bps >= 5000)Arbiter
DISPUTEDRESOLVED_TO_PAYERresolve(bps < 5000)Arbiter
DISPUTEDRESOLVED_SPLITresolve(0 < bps < 10000)Arbiter
DISPUTEDRESOLVED_TO_PAYERrefundUnresolvedDispute() (arbitration timeout)Anyone

Terminal states: RELEASED, REFUNDED, RESOLVED_TO_MERCHANT, RESOLVED_TO_PAYER, RESOLVED_SPLIT


5. Data Structures

5.1 Escrow Record

solidity
struct Escrow {
    address payer;           // Payer address
    address merchant;        // Merchant recipient address
    uint256 amount;          // Token amount
    bytes32 orderRef;        // Merchant order reference hash
    bytes32 merchantDid;     // Merchant DID hash
    bytes32 contextHash;     // Order context hash
    uint256 releaseDeadline; // Fulfillment deadline (ms, PlatON)
    uint256 disputeDeadline; // Dispute window deadline (ms)
    EscrowStatus status;     // Current status
    uint16 feeBps;           // Fee rate snapshot at deposit time
}

5.2 Batch Entry

solidity
struct BatchEntry {
    bytes32 paymentId;
    address merchant;
    uint256 amount;
    bytes32 orderRef;
    bytes32 merchantDid;
    bytes32 contextHash;
}

5.3 Constants

solidity
uint16 constant MAX_FEE_BPS = 500;     // 5% hard cap
uint256 constant MAX_BATCH_SIZE = 20;   // Prevents Gas griefing

6. Core Functions

6.1 Deposit Functions

depositWithAuthorization -- Single EIP-3009 Deposit

solidity
function depositWithAuthorization(
    bytes32 _paymentId,
    address _from,
    address _merchant,
    uint256 _amount,
    bytes32 _orderRef,
    bytes32 _merchantDid,
    bytes32 _contextHash,
    uint256 _validAfter,
    uint256 _validBefore,
    bytes32 _nonce,
    uint8 _v, bytes32 _r, bytes32 _s
) external nonReentrant

Called by the Relayer, using the user's EIP-3009 signature to transfer tokens from the user's wallet into the contract.

batchDepositGasless -- Relayer-Sponsored Batch Deposit (v4.3.0 primary entry point)

solidity
function batchDepositGasless(
    BatchEntry[] calldata entries,
    uint256 totalAmount,
    bytes32 groupId,
    uint8 groupV, bytes32 groupR, bytes32 groupS,
    address payer,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v, bytes32 r, bytes32 s
) external nonReentrant

This is the primary entry point for v4.3.0. Flow:

  1. Verify entries.length <= MAX_BATCH_SIZE
  2. Verify Group Signature (EIP-712): recover signer from groupV/R/S, verify it is a coreOperator
  3. Call token.transferWithAuthorization(payer, address(this), totalAmount, ...) -- transfer the total amount in a single call
  4. Create an independent Escrow record for each entry (with feeBps snapshot)
  5. Emit Deposited event for each entry + BatchDeposited event

batchDepositWithGroupApproval -- User-Submitted Batch Deposit

solidity
function batchDepositWithGroupApproval(
    BatchEntry[] calldata entries,
    uint256 totalAmount,
    bytes32 groupId,
    uint8 groupV, bytes32 groupR, bytes32 groupS,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v, bytes32 r, bytes32 s
) external nonReentrant

Retained for backward compatibility. Difference from batchDepositGasless: payer = msg.sender (user submits directly, pays their own Gas).

6.2 Release Functions

release -- Merchant/Operator Releases Funds

solidity
function release(bytes32 _paymentId) external nonReentrant
  • Caller: merchant or coreOperator
  • Precondition: status == DEPOSITED
  • Logic: deducts feeBps (snapshot value) as protocol fee, transfers remainder to merchant
  • Emit: PaymentReleased(paymentId, merchant, merchantAmount, fee)

6.3 Refund Functions

refund -- Timeout Refund

solidity
function refund(bytes32 _paymentId) external nonReentrant
  • Caller: anyone (public)
  • Precondition: status == DEPOSITED && block.timestamp > releaseDeadline
  • Emit: PaymentRefunded(paymentId, payer, amount)

cancelByOperator -- Instant Cancel Refund (v4.1.0)

solidity
function cancelByOperator(bytes32 _paymentId) external nonReentrant
  • Caller: coreOperator or merchant
  • Precondition: status == DEPOSITED (no timeout wait required)
  • Emit: PaymentRefunded(paymentId, payer, amount)

Difference: refund() requires waiting for releaseDeadline to expire; cancelByOperator() refunds immediately.

refundUnresolvedDispute -- Arbitration Timeout Refund (new in v4.0.0)

solidity
function refundUnresolvedDispute(bytes32 _paymentId) external nonReentrant
  • Caller: anyone (public)
  • Precondition: status == DISPUTED && block.timestamp > disputeDeadline + arbitrationTimeout
  • Logic: full refund to payer
  • Emit: DisputeAutoResolved(paymentId, payer, amount)

6.4 Dispute Functions

dispute -- Initiate Dispute

solidity
function dispute(bytes32 _paymentId, string calldata _reason) external nonReentrant
  • Caller: payer
  • Precondition: status == DEPOSITED && block.timestamp <= disputeDeadline
  • Emit: PaymentDisputed(paymentId, payer, reason)

resolve -- Arbitration Ruling

solidity
function resolve(bytes32 _paymentId, uint16 _merchantBps) external nonReentrant
  • Caller: arbiter
  • Precondition: status == DISPUTED
  • Parameter: _merchantBps (0-10000), the proportion awarded to the merchant
    • 10000 = all to merchant -> RESOLVED_TO_MERCHANT
    • 0 = all to payer -> RESOLVED_TO_PAYER
    • Other = proportional split -> RESOLVED_SPLIT (if 0 < bps < 10000)
  • Emit: DisputeResolved(paymentId, arbiter, toMerchant, merchantAmount, payerAmount)

6.5 Query Functions

solidity
function getEscrow(bytes32 _paymentId) external view returns (Escrow memory)
function isRefundable(bytes32 _paymentId) external view returns (bool)
function isDisputable(bytes32 _paymentId) external view returns (bool)
function isGroupIdUsed(bytes32 _groupId) external view returns (bool)
function computeDomainSeparator() external view returns (bytes32)

6.6 Admin Functions

FunctionPurposeCaller
setArbiter(address, bool)Add/remove arbiterowner
setCoreOperator(address, bool)Add/remove operator addressowner
setDefaultReleaseTimeout(uint256)Set default fulfillment timeoutowner
setDefaultDisputeWindow(uint256)Set default dispute windowowner
setArbitrationTimeout(uint256)Set arbitration timeoutowner
setProtocolFeeBps(uint16)Set protocol fee rate (<=500)owner
setProtocolFeeRecipient(address)Set protocol fee recipient addressowner
setRequireGroupSig(bool)Enforce Group Sig function usageowner
setToken(address)Switch ERC-3009 tokenowner

7. Group Signature (Anti-MITM)

7.1 EIP-712 Domain

solidity
bytes32 constant NEXUS_GROUP_APPROVAL_TYPEHASH = keccak256(
    "NexusGroupApproval(bytes32 groupId,bytes32 entriesHash,uint256 totalAmount)"
);

// Domain: Nexus, version 1, chainId, verifyingContract = escrow proxy

7.2 Signing Flow

  1. Core builds the BatchDepositInstruction and computes entriesHash:
    entriesHash = keccak256(
        abi.encode(entry0.paymentId, entry0.merchant, entry0.amount, ...) ||
        abi.encode(entry1.paymentId, entry1.merchant, entry1.amount, ...) ||
        ...
    )
  2. Core Operator signs NexusGroupApproval(groupId, entriesHash, totalAmount)
  3. The signature is appended to the BatchDepositInstruction and returned to the frontend
  4. On-chain batchDepositGasless() verifies: ecrecover(digest, v, r, s) == coreOperator

7.3 Protection Goals

  • Prevent man-in-the-middle tampering with batch entries (amounts, merchant addresses)
  • Prevent replay attacks (usedGroupIds mapping)
  • Ensure totalAmount == sum(entries.amount)

8. EIP-3009 Integration

8.1 Interface

solidity
interface IERC3009 is IERC20 {
    function transferWithAuthorization(
        address from, address to, uint256 value,
        uint256 validAfter, uint256 validBefore, bytes32 nonce,
        uint8 v, bytes32 r, bytes32 s
    ) external;
    function authorizationState(address authorizer, bytes32 nonce) external view returns (bool);
}

8.2 Signature Parameters

EIP-712 TypedData signed by the user:

typescript
{
  domain: {
    name: "XSGD",       // Token contract EIP-712 name
    version: "2",        // Token contract EIP-712 version
    chainId: 20250407,
    verifyingContract: tokenAddress
  },
  types: {
    TransferWithAuthorization: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" }
    ]
  },
  message: {
    from: payerAddress,
    to: escrowProxyAddress,
    value: totalAmountUint256,
    validAfter: "0",
    validBefore: Date.now() + 86400000,  // +24h (milliseconds, PlatON)
    nonce: randomBytes32
  }
}

PlatON Devnet Note: block.timestamp returns milliseconds. validBefore must be set in milliseconds.


9. Event Definitions

solidity
// Deposit events
event Deposited(bytes32 indexed paymentId, address indexed payer, address indexed merchant,
    uint256 amount, bytes32 orderRef, bytes32 merchantDid, bytes32 contextHash,
    uint256 releaseDeadline, uint256 disputeDeadline);

event BatchDeposited(bytes32 indexed groupId, address indexed payer,
    uint256 totalAmount, uint256 count);

// Release events
event Released(bytes32 indexed paymentId, address indexed merchant,
    uint256 merchantAmount, uint256 protocolFee);

// Refund events
event Refunded(bytes32 indexed paymentId, address indexed payer, uint256 amount);

// Dispute events
event Disputed(bytes32 indexed paymentId, address indexed payer, string reason);

event Resolved(bytes32 indexed paymentId, address indexed arbiter,
    uint16 merchantBps, uint256 merchantAmount, uint256 payerAmount);

event DisputeAutoResolved(bytes32 indexed paymentId, address indexed payer, uint256 amount);

// Group Sig events
event GroupSigVerified(bytes32 indexed groupId, address indexed signer);

// Admin events
event ArbiterUpdated(address indexed arbiter, bool active);
event CoreOperatorUpdated(address indexed operator, bool active);
event ReleaseTimeoutUpdated(uint256 newTimeout);
event DisputeWindowUpdated(uint256 newWindow);
event ArbitrationTimeoutUpdated(uint256 newTimeout);
event ProtocolFeeUpdated(uint16 newBps);
event FeeRecipientUpdated(address indexed newRecipient);
event RequireGroupSigUpdated(bool required);
event TokenUpdated(address indexed newToken);

10. Deployment Information

10.1 Current Deployment (PlatON Devnet, chain_id: 20250407)

ContractAddressType
NexusEscrow (Proxy)0xeB33a9C2b4c7D3F44Fd5514F90C355AF6bb79236UUPS Proxy
NexusEscrow (Impl v4.3.0)0xF6ED311f8ea594572872E78DB945277ab37ECE37Implementation
XSGD Token0x0Fd437613dE3d14F4dDaB8331DC0f2C0C543BdD0ERC-3009
USDC Token (legacy)0xFF8dEe9983768D0399673014cf77826896F97e4dERC-3009
Relayer / Core Operator0xf7EA5d3f0Bf8185c4f3C2F405D9a71009CF4D920EOA

10.2 Current Configuration

ParameterValueDescription
defaultReleaseTimeout86400000 (24h, ms)Merchant fulfillment timeout
defaultDisputeWindow259200000 (72h, ms)User dispute window
arbitrationTimeout604800000 (7d, ms)Arbiter ruling timeout
protocolFeeBps30 (0.3%)Protocol fee
requireGroupSigfalseGroup Sig enforcement toggle
Active tokenXSGDCurrently active token

10.3 Version History

VersionImpl AddressDateKey Changes
v2.0.0(non-upgradeable, deprecated)2026-02-24Initial escrow (single deposit)
v4.0.00x2EF4dB5E0021d074286c36821Cc897d2605e542E2026-02-26UUPS, batchDepositWithGroupApproval, RESOLVED_SPLIT, refundUnresolvedDispute, MAX_BATCH_SIZE, feeBps snapshot
v4.1.00x7aC2e9C7D655B352f142b679B22f4B86b23A36eC2026-03-12cancelByOperator (instant refund)
v4.3.00xF6ED311f8ea594572872E78DB945277ab37ECE372026-04-01batchDepositGasless (Relayer-sponsored), setToken (multi-token)

11. Gas Cost Estimates

OperationGas ConsumptionUser PaysRelayer Pays
EIP-3009 signing000
batchDepositGasless (1 entry)~180,0000180,000
batchDepositGasless (5 entries)~450,0000450,000
release~80,000080,000
refund~75,000075,000
cancelByOperator~75,000075,000
dispute~60,00060,0000
resolve~90,000090,000

Under the escrow model, users only need Gas when actively initiating a dispute. All other operations are covered by the Relayer.


12. Security Design

12.1 Access Control

FunctionPermission
deposit / batchDepositGaslessAnyone (typically Relayer)
releasecoreOperator or merchant
refundAnyone (after timeout)
cancelByOperatorcoreOperator or merchant
disputepayer
resolvearbiter
refundUnresolvedDisputeAnyone (after arbitration timeout)
All set* admin functionsowner
_authorizeUpgradeowner

12.2 Security Mechanisms

  1. ReentrancyGuard: All state-changing functions are protected with nonReentrant
  2. Input Validation: Zero address, zero amount, and self-payment checks
  3. Fee Cap: MAX_FEE_BPS = 500 (5% hard cap)
  4. Batch Size Limit: MAX_BATCH_SIZE = 20 (prevents Gas griefing)
  5. Fee Snapshot: feeBps is recorded at deposit time; subsequent fee changes do not affect existing escrows
  6. Group ID Replay Protection: usedGroupIds mapping prevents replay attacks
  7. Group Sig Verification: On-chain verification of Core Operator signature

12.3 PlatON Devnet Special Notes

  • block.timestamp returns milliseconds (non-standard EVM behavior)
  • All timeout parameters (releaseTimeout, disputeWindow, arbitrationTimeout) are configured in milliseconds
  • EIP-3009 validBefore must also use millisecond timestamps

13. Upgrade Path

Current (v4.3.0)Future
Single chain (PlatON Devnet)Multi-chain deployment (Base, Ethereum)
Single active tokenMulti-token concurrent (per-payment token selection)
Operator arbitrationDAO governance arbitration
Fixed fee rateDynamic fee rates (by volume, merchant tier)
UUPS owner upgradeTimelock + multisig upgrade governance

Copyright (c) 2026 Nexus Protocol. All Rights Reserved.