newsence
來源篩選

[Draft ERC] VOSA-RWA: Compliance-Gated Privacy Token for Real World Assets

Ethereum Magicians

newsence

[Draft ERC] VOSA-RWA: Compliance-Gated Privacy Token for Real World Assets

Ethereum Magicians
大約 11 小時前

Field
Value

Status
Draft

Type
Standards Track

Category
ERC

Requires
pERC-20 (Private ERC-20, Draft), EIP-712, ERC-5564

Abstract

VOSA-RWA defines a pattern for issuing compliance-gated privacy tokens for Real World Assets. It extends with a generic compliance module — every state-changing operation (transfer, mint, burn) requires an on-chain-verified ZK proof that the operation satisfies off-chain compliance requirements (KYC, AML, sanctions, whitelist). No compliance data or PII is stored on-chain.

The key insight: compliance and privacy are not in conflict. VOSA-RWA achieves both simultaneously — balances and transfer amounts are hidden via Poseidon commitments and Groth16 proofs, while every operation is gated by a ZK compliance proof verified on-chain.

Motivation

The RWA Compliance Problem

Tokenized Real World Assets (securities, bonds, real estate, commodities) face a fundamental tension:

Requirement
Standard ERC-20
Privacy Tokens (Tornado)
VOSA-RWA

Balance privacy

Transfer amount privacy

KYC/AML enforcement
(allowlist)

(ZK proof)

No PII on-chain
N/A
N/A

Audit trail

(events)

Regulatory compliance

Existing approaches force a choice:

  • ERC-3643 (T-REX): On-chain identity registry + claim-based compliance. Compliance is enforced, but address-level holdings and compliance status are publicly observable.

  • Privacy tokens: Balance privacy, but no compliance mechanism. Incompatible with securities regulations.

VOSA-RWA combines both: compliance is enforced per-operation via ZK proofs, while balances remain hidden.

Why ZK Compliance Instead of On-Chain Allowlists?

Approach
On-Chain Data
Privacy
Flexibility

On-chain allowlist (ERC-3643)
Full identity registry
Public who holds what
Requires on-chain update per user

ZK compliance (VOSA-RWA)
Only: allowed key hashes
No PII, no allowlist
New compliance service = add key hash

With ZK compliance:

  • Adding a new compliance provider = calling setAllowedKeyHash(hash, true). No circuit change, no contract upgrade.

  • Revoking a provider = calling setAllowedKeyHash(hash, false). Immediate effect.

  • The compliance service can implement any policy (KYC, AML, sanctions, accredited investor checks) without the contract knowing or caring what was checked.

Specification

Overview

Off-chain On-chain
┌─────────────────────┐ proof ┌─────────────────────────┐
│ Compliance Service │────────────▶│ ComplianceModule │
│ KYC/AML/sanctions │ │ verifyComplianceProof()│
│ whitelist/blacklist │ └──────────┬──────────────┘
└─────────────────────┘ │ ok

┌─────────────────────────┐
│ RWAERC20 │
│ transferWithCompliance()│
│ mintWithCompliance() │
│ burnWithCompliance() │
└─────────────────────────┘

RWAERC20 extends PrivateERC20 (pERC-20) and overrides all direct operations to revert. Only compliance-gated entry points are usable:

contract RWAERC20 is PrivateERC20 {
IComplianceModule private _compliance;
mapping(bytes32 => bool) private _usedContexts;

}

Compliance Flow

  1. User builds operation (e.g. transfer inputs/outputs)
  2. User requests compliance proof from off-chain service:
    → Service checks KYC/AML/sanctions/whitelist
    → Service generates ZK proof: Poseidon(secret) == keyHash, bound to context
    → Returns (proof, context, keyHash)
  3. User calls transferWithCompliance(proof, context, keyHash, ...transfer params...)
  4. Contract:
    a. Computes boundContext = keccak256(abi.encode(address(this), context))
    b. Checks boundContext not already used (replay protection)
    c. Marks boundContext as used
    d. Calls compliance.verifyComplianceProof(proof, boundContext, keyHash)
    e. If ok, executes the transfer via _executeTransfer()

ComplianceModule Interface

The compliance module is a generic, reusable contract — not specific to RWA. Any privacy token can integrate it.

interface IComplianceModule {
event ComplianceProofVerified(
address indexed caller, bytes32 context, bytes32 verifierId, bytes32 keyHash
);

}

ComplianceModule Implementation

contract ComplianceModule is IComplianceModule {
IGroth16Verifier public immutable defaultVerifier;
mapping(bytes32 => bool) private _allowedKeyHashes;
mapping(bytes32 => address) private _verifiers; // for circuit versioning

}

RWAERC20 Interface

Constructor

constructor(
string memory name_,
string memory symbol_,
uint8 decimals_,
address transferVerifier_, // ZK verifier for transfers (from pERC-20)
address amountVerifier_, // ZK verifier for mint/burn (from pERC-20)
address poseidon_, // Poseidon hash contract
address complianceModule_ // ComplianceModule address
)

Compliance-Gated Operations

/// @notice Transfer with compliance proof
function transferWithCompliance(
bytes calldata proof, // Compliance ZK proof
bytes32 context, // Operation context (bound to address(this) on-chain)
bytes32 keyHash, // Compliance service key hash
address[] calldata inputs, // Input VOSA addresses
bytes32[] calldata inputCommitments,
address[] calldata outputs, // Output VOSA addresses
bytes32[] calldata outputCommitments,
uint256[] calldata outputTimestamps,
bytes[] calldata ephemeralPubKeys,
bytes[] calldata signatures, // EIP-712 ECDSA signatures
bytes calldata zkProof, // Transfer ZK proof (amount conservation)
uint256 deadline,
int256[] calldata policyChangeIndices,
bytes calldata memo
) external nonReentrant whenNotPaused returns (bool);

/// @notice Mint with compliance proof (only allowed minter)
function mintWithCompliance(
bytes calldata proof,
bytes32 context,
bytes32 keyHash,
uint256 amount,
address recipient,
bytes32 commitment,
uint256 outputTimestamp,
bytes calldata ephemeralPubKey,
bytes calldata zkProof,
bytes calldata memo
) external onlyAllowedMinter nonReentrant whenNotPaused returns (bool);

/// @notice Burn with compliance proof
function burnWithCompliance(
bytes calldata proof,
bytes32 context,
bytes32 keyHash,
address input,
bytes32 inputCommitment,
uint256 amount,
address changeAddress,
bytes32 changeCommitment,
uint256 changeTimestamp,
bytes calldata changeEphemeralPubKey,
bytes calldata signature,
bytes calldata zkProof,
uint256 deadline
) external nonReentrant whenNotPaused returns (bool);

View Functions

function complianceModule() external view returns (address);
function allowedMinter() external view returns (address);
function isContextUsed(bytes32 context) external view returns (bool);

Admin Functions

function setComplianceModule(address complianceModule_) external onlyOwner;
function setAllowedMinter(address allowedMinter_) external onlyOwner;

Events

// Compliance-gated operation events
event ComplianceGatedTransfer(bytes32 indexed context, bytes32 keyHash);
event ComplianceGatedMint(bytes32 indexed context, bytes32 keyHash);
event ComplianceGatedBurn(bytes32 indexed context, bytes32 keyHash);

// Governance events
event AllowedMinterChanged(address indexed previousMinter, address indexed newMinter);
event ComplianceModuleChanged(address indexed previousModule, address indexed newModule);

// ComplianceModule event (emitted on every successful verification)
event ComplianceProofVerified(address indexed caller, bytes32 context, bytes32 verifierId, bytes32 keyHash);

Attestation Circuit

The compliance proof uses a minimal Poseidon-based attestation circuit:

Public inputs: context, keyHash
Private input: secret

Constraint: Poseidon(secret) == keyHash

The compliance service holds a secret and registers keyHash = Poseidon(secret) on-chain. To attest to an operation, it generates a Groth16 proof with public (context, keyHash) and private secret. The proof demonstrates knowledge of the secret without revealing it.

Property
Value

Constraints
~213

Proving time (snarkjs)
~922 ms

Proving time (rapidsnark)
~44 ms

Public signals
2 (context, keyHash)

Curve
BN254

Hash
Poseidon (1 input)

Cross-Contract Replay Prevention

RWAERC20 enforces context binding on-chain:

function _boundContext(bytes32 context) internal view returns (bytes32) {
return keccak256(abi.encode(address(this), context));
}

The compliance service must compute the same boundContext when generating the proof. This prevents a compliance proof issued for contract A from being used on contract B, even if they share the same ComplianceModule.

Dual-Proof Architecture

Each compliance-gated operation requires two independent ZK proofs:

  • Compliance proof (attestation circuit): proves the compliance service has approved this specific operation

  • Transaction proof (pERC-20 circuit): proves amount conservation / commitment correctness

These are verified independently — the compliance module checks proof #1, the base PrivateERC20 logic checks proof #2. Neither proof reveals any information to the other.

Rationale

Why Subclass PrivateERC20 Instead of Wrapping?

Subclassing gives direct access to internal _executeMint / _executeBurn / _executeTransfer, avoiding external self-calls and their issues (broken nonReentrant, extra gas, signature mismatch). The override pattern is clean: block direct calls, gate through compliance, then delegate to the parent’s internal logic.

Why Not Store Compliance State On-Chain?

On-chain allowlists (like ERC-3643) expose who is allowed to hold the token. This leaks information about KYC status, accreditation, jurisdiction. With ZK compliance:

  • The on-chain contract only stores keyHash values (opaque identifiers for compliance services)

  • Whether a specific user passed KYC is never recorded on-chain

  • The compliance service can implement any policy without smart contract changes

Why Separate Compliance Module?

The ComplianceModule is a standalone contract, not embedded in RWAERC20. Benefits:

  • Reusable: Multiple RWA tokens can share one ComplianceModule

  • Upgradable: setComplianceModule() allows swapping without redeploying the token

  • Multi-provider: Multiple compliance services (each with their own keyHash) can coexist

Why Block consolidate / createPolicy / createPermit?

For strict RWA compliance, every value movement must have a compliance attestation. Consolidate (merging UTXOs) changes the VOSA structure without compliance check. Policy/Permit delegation could allow unauthorized parties to transfer regulated assets. These are blocked in the RWA variant; the base PrivateERC20 retains them for non-regulated use cases.

Performance

Gas Consumption

Measured on Hardhat local network, Solidity 0.8.24 with viaIR + optimizer (runs=1), real Poseidon (BN254), mock Groth16 verifiers. Add ~200K per real Groth16 verification.

Operation
Gas

mintWithCompliance
189,999

transferWithCompliance
164,973

burnWithCompliance
236,787

Compliance Proof Generation

Prover
Witness
Prove
Total

snarkjs (WASM)
~91 ms
~922 ms
~1.0 s

rapidsnark (C++)
~192 ms
~44 ms
~236 ms

Note: The total proof time for a compliance-gated transfer is the sum of the compliance proof (~236 ms) and the transfer proof (~70 ms with rapidsnark), totaling ~300 ms.

Security Considerations

Compliance Bypass Prevention

All six inherited operations from PrivateERC20 (transfer, mint, burn, consolidate, createPolicy, createPermit) are overridden to revert with descriptive errors. There is no code path that allows a state change without a valid compliance proof.

CEI Pattern (Checks-Effects-Interactions)

_requireCompliance marks the context as used before making the external call to verifyComplianceProof. This prevents a malicious or upgradeable compliance module from re-entering the contract and reusing the same proof.

Reentrancy Protection

All three compliance-gated entry points have nonReentrant. Combined with the CEI pattern in _requireCompliance, there is no reentrancy window.

Caller Restrictions

  • mintWithCompliance: restricted to allowedMinter or owner (via onlyAllowedMinter modifier)

  • transferWithCompliance and burnWithCompliance: callable by anyone, but require a valid compliance proof AND a valid EIP-712 ECDSA signature from the VOSA owner. The caller (msg.sender) is not checked — this is by design, enabling relayer/meta-transaction patterns.

Signature Coverage

Inherited from pERC-20: EIP-712 signatures cover policyChangeIndices and ephemeralPubKeys, preventing frontrunning substitution.

Burn Authorization

Inherited from pERC-20: burn always requires the owner’s signature, not the delegate’s. Policy/permit spenders cannot redeem RWA assets.

Context Replay Protection

  • Same-contract replay: prevented by _usedContexts[boundContext] = true

  • Cross-contract replay: prevented by boundContext = keccak256(abi.encode(address(this), context))

  • Cross-chain replay: prevented by EIP-712 DOMAIN_SEPARATOR (includes chainId)

Compliance Module Trust

The compliance module is a trusted external contract. If compromised, an attacker could approve unauthorized operations. Mitigations:

  • Owner should be a multisig (Gnosis Safe)

  • setComplianceModule emits ComplianceModuleChanged for monitoring

  • setVerifier validates code.length > 0

  • transferOwnership rejects address(0)

Backwards Compatibility

VOSA-RWA is a new contract standard with no backward compatibility requirements. It does not implement the ERC-20 interface. The ComplianceModule is a standalone contract usable by any token that needs compliance gating.

Reference Implementation

VOSA/
├── vosa-rwa/
│ └── contracts/
│ ├── src/
│ │ ├── RWAERC20.sol # Main contract (extends PrivateERC20)
│ │ ├── poseidon/
│ │ │ └── RealPoseidon.sol # BN254 Poseidon (PoseidonT3/T4/T5)
│ │ └── mocks/
│ │ ├── MockComplianceModule.sol
│ │ ├── MockGroth16Verifier.sol
│ │ └── MockPoseidon.sol
│ ├── test/
│ │ ├── RWAERC20.test.ts # Unit tests (30 passing)
│ │ ├── RWAERC20.integration.test.ts # Full flow: mint → transfer → burn
│ │ ├── Compliance.and.audit.test.ts # ComplianceModule integration
│ │ └── RWA.product.integration.test.ts # RWA product scenarios
│ └── scripts/
│ ├── deploy.ts
│ ├── deploy-with-real.ts # Real Poseidon + ComplianceModule
│ ├── gas-benchmark.ts
│ └── proof-time-benchmark.ts # snarkjs + rapidsnark
├── erc20-native/ # PrivateERC20 base (pERC-20)
│ └── contracts/src/PrivateERC20.sol
├── compliance/ # Standalone compliance module
│ ├── contracts/
│ │ ├── IComplianceModule.sol
│ │ ├── ComplianceModule.sol
│ │ └── IGroth16Verifier.sol
│ ├── circuits/
│ │ ├── attestation.circom # Poseidon(secret) == keyHash (~213 constraints)
│ │ └── scripts/
│ │ ├── compile.sh
│ │ └── setup.sh
│ └── service/ # Off-chain compliance service reference
│ ├── src/
│ │ ├── checks.ts # KYC/AML/sanctions/whitelist
│ │ ├── attestation.ts # Key management
│ │ ├── proof.ts # Proof generation
│ │ └── context.ts # Context construction
│ └── data/
│ ├── kyc.json, aml.json, whitelist.json, blacklist.json

Questions for Discussion

  • Generic compliance vs RWA-specific: Should the compliance module be standardized as a standalone ERC (usable by any token), or bundled with the RWA token standard?

  • Compliance proof expiration: The current design has no built-in proof TTL — expiration relies on the business transaction’s deadline. Should the attestation circuit include a timestamp?

  • Frozen assets: VOSA’s one-time address model makes on-chain address freezing impractical (user can transfer before freeze). The design relies on the compliance service refusing to issue proofs for frozen users. Is this sufficient for regulatory requirements?

  • Multi-jurisdiction: Different jurisdictions have different compliance requirements. Should the standard support per-operation verifier selection (already possible via verifyComplianceProof(verifierId, ...)), or is this an application-layer concern?

  • Interop with ERC-3643: Could VOSA-RWA tokens interoperate with existing T-REX infrastructure (using the ZK compliance module as an identity verification bridge)?

Copyright

Copyright and related rights waived via .