[Draft ERC] VOSA-RWA: Compliance-Gated Privacy Token for Real World Assets
Ethereum Magicians
Ethereum Magicians
Field
Value
Status
Draft
Type
Standards Track
Category
ERC
Requires
pERC-20 (Private ERC-20, Draft), EIP-712, ERC-5564
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.
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.
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.
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;
}
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
);
}
contract ComplianceModule is IComplianceModule {
IGroth16Verifier public immutable defaultVerifier;
mapping(bytes32 => bool) private _allowedKeyHashes;
mapping(bytes32 => address) private _verifiers; // for circuit versioning
}
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
)
/// @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);
function complianceModule() external view returns (address);
function allowedMinter() external view returns (address);
function isContextUsed(bytes32 context) external view returns (bool);
function setComplianceModule(address complianceModule_) external onlyOwner;
function setAllowedMinter(address allowedMinter_) external onlyOwner;
// 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);
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)
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.
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.
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.
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
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
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.
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
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.
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.
_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.
All three compliance-gated entry points have nonReentrant. Combined with the CEI pattern in _requireCompliance, there is no reentrancy window.
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.
Inherited from pERC-20: EIP-712 signatures cover policyChangeIndices and ephemeralPubKeys, preventing frontrunning substitution.
Inherited from pERC-20: burn always requires the owner’s signature, not the delegate’s. Policy/permit spenders cannot redeem RWA assets.
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)
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)
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.
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
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 and related rights waived via .
// These revert — RWA requires compliance for all operationsfunction transfer(...) → revert UseTransferWithCompliance()function mint(...) → revert UseMintWithCompliance()function burn(...) → revert UseBurnWithCompliance()function consolidate(...) → revert ConsolidateNotSupported()function createPolicy(...) → revert CreatePolicyNotSupported()function createPermit(...) → revert CreatePermitNotSupported()// Only these are usablefunction transferWithCompliance(proof, context, keyHash, ...) external;function mintWithCompliance(proof, context, keyHash, ...) external;function burnWithCompliance(proof, context, keyHash, ...) external;/// @notice Verify compliance proof using default verifierfunction verifyComplianceProof( bytes calldata proof, bytes32 context, bytes32 keyHash) external returns (bool);/// @notice Verify compliance proof using a specific verifier (for circuit versioning)function verifyComplianceProof( bytes32 verifierId, bytes calldata proof, bytes32 context, bytes32 keyHash) external returns (bool);function verifyComplianceProof(bytes calldata proof, bytes32 context, bytes32 keyHash) external returns (bool){ if (!_allowedKeyHashes[keyHash]) revert KeyHashNotAllowed(); uint256[] memory publicInputs = new uint256[](2); publicInputs[0] = uint256(context); publicInputs[1] = uint256(keyHash); if (!defaultVerifier.verify(proof, publicInputs)) revert VerificationFailed(); emit ComplianceProofVerified(msg.sender, context, bytes32(0), keyHash); return true;}function setAllowedKeyHash(bytes32 keyHash, bool allowed) external onlyOwner;function setVerifier(bytes32 verifierId, address verifier) external onlyOwner;function removeVerifier(bytes32 verifierId) external onlyOwner;function transferOwnership(address newOwner) external onlyOwner; 1 post - 1 participant [Read full topic](https://ethereum-magicians.org/t/draft-erc-vosa-rwa-compliance-gated-privacy-token-for-real-world-assets/27908)