# Part 1 Tutorial: Tokenizing Using ERC-3643 **Published by:** [0xDanki ( Tin Erispe )](https://paragraph.com/@tinerispe/) **Published on:** 2026-04-04 **Categories:** rwa, erc-3643, tutorial, ethereum, solidity **URL:** https://paragraph.com/@tinerispe/part-1-tutorial-tokenizing-using-erc-3643 ## Content RWAs are having a moment. Everyone suddenly wants to “tokenize assets” like it’s just another token deployment with more serious looking names attached to them. It's not. An RWA system is:permissionedidentity-awarecompliance-heavyand very, very allergic to “just ship it” instincts that we've grown accustomed toSo let's try it. Let's build a demo tokenized stock using ERC-3643 — with identity, compliance, oracle-fed data, and a redemption path. What you'll learn:How to restrict token transfers to verified investors onlyWhy compliance needs to be modular (spoiler: regulations change)Why redemption can't be instant (spoiler: blockchains are synchronous, stock markets aren't)Where oracles fit (and where they don't)What you won't learn:How to avoid securities regulations (you can't)How to launch this in production (you shouldn't, without serious infrastructure and security processes)How to get rich quick (wrong tutorial)Let's go mah frens 🐴 ---Why You're Getting Mocks...Full disclosure: I built a fully functional version of this using Alpaca API for permissionless stock trading. It works elegantly. And I'm never shipping it publicly. Why? First of all, offering securities without proper licensing is a regulatory nightmare "But it's decentralized!" is not a legal defense (ask the Luna folks), and Danki prefers devrelling over eating prison food. Nevertheless, I still teach you the exact same architecture with mock infrastructure. You'll understand how ERC-3643 is implemented. We'll use commonly used compliance patterns. With real async redemption flow (because T+2 settlement is reality). We'll even use real oracle integration patterns. The only difference is we'll have mock oracles instead of live API calls. When you're ready to go live (with proper licensing), you swap 50 lines of mock code for real API calls. Oh, and a bonus: I'll show you how this applies to Philippine-based assets (PSE stocks, real estate tokens) where the regulatory path is clearer.Synthetic vs Backed RWAs (Stop Confusing Them)Before we build anything, let’s clean this up. A synthetic RWA gives you exposure. A backed RWA gives you a claim. If you back an RWA with something that lives onchain (eg. wBTC, ETH, USDC, LUNA), it's a synthetically backed asset. In synthetically backed RWA, you can't actually claim the asset, but you can claim the tokens backing the value that it represents. If you're RWA is backed by the exact asset that it represents, then it's an asset-backed RWA. This type is enforceable, and should be redeemable. The downside is that it is reliant on oracles that brings your offchain data onchain. For this tutorial, we’re modeling a backed RWA architecture. That means:the token represents a claimthe holder must be eligibletransfers are restrictedredemption existsIf any of those are missing… you already know 🐴Components of an RWA SystemBefore we touch code, we need to understand the moving parts. ┌─────────────────────────────────────────────────────────────┐ │ StockToken │ │ Main ERC20 token with identity and compliance integration │ └────────────┬────────────────────────────────────────────────┘ │ ┌────────┴────────┬──────────────┬─────────────┐ │ │ │ │ ▼ ▼ ▼ ▼ ┌─────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────────┐ │ Identity │ │ Compliance │ │ Oracles │ │ Redemption │ │ Registry │ │ (Modular) │ │ │ │ Manager │ └─────────┘ └──────────────┘ └─────────┘ └──────────────┘ 1. Contracts (The Stack, Not Just the Token)ERC-3643 is a suite, not a single contract. At minimum, you’re working with:Token (StockToken) → the actual asset representationIdentity Registry → who is allowed to holdClaim Topics Registry → what requirements exist (KYC, accreditation, etc.)Trusted Issuers Registry → who is allowed to verify usersCompliance Contract → decides if transfers are allowedOptional:Oracle contracts → price, reserves, eventsRedemption manager → where offchain meets realityIf your system is just:mint( ); transfer( );😭 please close your laptop.2. Compliance Frameworks (Yes, You Need This)In ERC-3643 compliance means:wallets are mapped to identities who can carry claimsclaims come from trusted issuerscompliance checks happen on every transferWhich means:your token doesn’t just care about balances it cares about who is moving them3. Oracles (Plural, Not One Magic Pipe)You don’t have “an oracle.” You have multiple. For a tokenized stock, you might need:price oracle → “what is this stock worth?”reserve / attestation oracle → “is this actually backed?”compliance oracle → offchain KYC systemscorporate actions oracle → dividends, splits, etc.settlement signal → redemption completedOk, enough talking.Now, install your Foundry and Openzeppellin. They're our dependencies. Step 0 — Set Up Your RepoThis will be our file structure:/contracts /identity /compliance /oracle /redemption /token /test /integration /unitStep 1 — Identity First (Not Token First)Map wallets to identity:// src/identity/IdentityRegistry.sol contract IdentityRegistry is AccessControl { bytes32 public constant AGENT_ROLE = keccak256("AGENT_ROLE"); // Wallet → Identity contract mapping mapping(address => IIdentity) private _identities; mapping(address => uint16) private _investorCountry; function registerIdentity( address wallet, IIdentity identityContract, uint16 country ) external onlyRole(AGENT_ROLE) { require(wallet != address(0), "invalid wallet"); require(address(identityContract) != address(0), "invalid identity"); require(address(_identities[wallet]) == address(0), "already registered"); _identities[wallet] = identityContract; _investorCountry[wallet] = country; emit IdentityRegistered(wallet, identityContract); } function isVerified(address wallet) external view returns (bool) { return address(_identities[wallet]) != address(0); } function deleteIdentity(address wallet) external onlyRole(AGENT_ROLE) { IIdentity oldIdentity = _identities[wallet]; require(address(oldIdentity) != address(0), "not registered"); delete _identities[wallet]; delete _investorCountry[wallet]; emit IdentityRemoved(wallet, oldIdentity); } }No identity = no participation Some decisions I made: 1. Instead of storing KYC data on-chain (expensive, privacy nightmare), we store a reference to an identity contract. The identity contract holds claims issued by KYC providers. 2. Only authorized KYC providers can register identities. This isn't permissionless DeFi anymore. 3. I did a mock country tracking. Different countries have different rules. We need to know where investors are located. What this does NOT do:Store KYC dataEnforce transfer limitsCheck pricesWe're all doing these somewhere else, because separation of concerns matters. I let each contract do one thing well. Important note on identity: The full ERC-3643 standard uses claims for verification:interface IIdentity { function getClaim(bytes32 claimId) external view returns ( uint256 topic, // What type of claim (KYC, accreditation, etc.) uint256 scheme, // Signature scheme address issuer, // Who issued the claim bytes signature, // Cryptographic proof bytes data, // Claim data string uri // Additional info ); }And how it works in practice is:User completes KYC with providerProvider issues a claim (cryptographically signed)Claim is stored in user's ONCHAINID contractToken contract verifies claim before allowing transfers. Please don't skip this if you're using on prod. It's very important for future features because:KYC provider can revoke claims in case user gets sactionedMultiple providers can issue claims (no central provider)We have privacy-preserving considerations (claim data can be hashed) For this tutorial, we use simplified verification (just check if identity exists). Full claim verification can be added later without changing the architecture.Step 3 — Compliance LayerDifferent jurisdictions have different rules. Rules change over time. You can't hardcode compliance logic. So we'll have pluggable compliance modules// src/compliance/ModularCompliance.sol contract ModularCompliance is Ownable { IModule[] private _modules; function canTransfer(address from, address to, uint256 amount) external view returns (bool) { // Check ALL modules for (uint256 i = 0; i < _modules.length; i++) { if (!_modules[i].moduleCheck(from, to, amount, address(this))) { return false; // ANY module can reject } } return true; // ALL modules must approve } function transferred(address from, address to, uint256 amount) external onlyToken { // Notify ALL modules (for state updates) for (uint256 i = 0; i < _modules.length; i++) { _modules[i].moduleTransferAction(from, to, amount); } } }Notice we have two functions: canTransfer() (view function): Pre-transfer validation. Returns true/false. No state changes. transferred() (state-changing): Post-transfer state update. Modules update their internal state. Why this pattern? Because view functions can't change state. If modules need to track balances or daily limits, they need a separate function called AFTER the transfer succeeds. This is the part that trips people up. Let me show you why it matters. Here's an example module I created, Max Balance Limit (yor regular AMLA module):// src/compliance/modules/MaxBalanceModule.sol contract MaxBalanceModule is AbstractModule { uint256 public maxBalance; // Track balance per IDENTITY (not per wallet) mapping(address => uint256) public identityBalance; function moduleCheck(address /* from */, address to, uint256 amount, address /* compliance */) external view returns (bool) { address toIdentity = _getIdentity(to); // Check if receiver would exceed max balance if (identityBalance[toIdentity] + amount > maxBalance) { return false; } return true; } function moduleTransferAction(address from, address to, uint256 amount) external onlyBoundCompliance { address fromIdentity = _getIdentity(from); address toIdentity = _getIdentity(to); // Update balances if (from != address(0) && fromIdentity != address(0)) { identityBalance[fromIdentity] -= amount; } if (toIdentity != address(0)) { identityBalance[toIdentity] += amount; } } }Why track by identity, not wallet? Because one person can have multiple wallets. If we track by wallet, they can bypass limits by using 10 different addresses. And one more, here's a critical pitfall that will break your system: Don't check sender balance in moduleCheck(). The module's internal balance is only updated AFTER the transfer. Checking sender balance in moduleCheck() creates a deadlock where transfers always fail.// WRONG: This will always fail function moduleCheck(address from, address to, uint256 amount) { address fromIdentity = _getIdentity(from); // This checks module's internal balance, which hasn't been updated yet! if (identityBalance[fromIdentity] < amount) { return false; // DEADLOCK MAH FREN } // ... }here's how to do it correctly:// Token contract checks sender balance, module checks receiver limit function moduleCheck(address /* from */, address to, uint256 amount) { address toIdentity = _getIdentity(to); // Only check receiver's limit if (identityBalance[toIdentity] + amount > maxBalance) { return false; } return true; }So you see, between the token and the modules, there is separation of functions: Your Token contract: Validates sender has sufficient balance (ERC20 standard) Your Compliance module: Validates business rules (receiver limits, daily caps, etc.) Mix them up if you're having fun on test environments. But so far, this is what works. Let's try another AMLA module 🐴// src/compliance/modules/TransferLimitModule.sol contract TransferLimitModule is AbstractModule { uint256 public dailyLimit; // identity => day => volume mapping(address => mapping(uint256 => uint256)) public dailyTransferVolume; function moduleCheck(address from, address /* to */, uint256 amount, address /* compliance */) external view returns (bool) { address fromIdentity = _getIdentity(from); uint256 today = block.timestamp / 1 days; uint256 currentVolume = dailyTransferVolume[fromIdentity][today]; if (currentVolume + amount > dailyLimit) { return false; } return true; } function moduleTransferAction(address from, address /* to */, uint256 amount) external onlyBoundCompliance { address fromIdentity = _getIdentity(from); uint256 today = block.timestamp / 1 days; dailyTransferVolume[fromIdentity][today] += amount; } }Why daily limits?Prevents rapid liquidation (market manipulation)Complies with Reg D restrictions (limited resale)Detects suspicious activity (sudden large transfers = 🚨)Auto-reset: Counters automatically reset when today changes (new day = new timestamp / 1 days).Great, we've now set up an identity and compliance system.We now have:a system that knows who is allowed to participatea compliance layer that enforces rules at transfer timeand a clear separation between identity, policy, and token behaviorIn Part 2, we’ll wire this system into the outside world:bring in oracle dataimplement token behavior that reflects regulatory constraintsand design a redemption flow that doesn’t pretend blockchains can settle instantlyThat's all for now. Danki will be egg hunting. I'll be back on Easter! 🐴🐤🐰🥚 ## Publication Information - [0xDanki ( Tin Erispe )](https://paragraph.com/@tinerispe/): Publication homepage - [All Posts](https://paragraph.com/@tinerispe/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@tinerispe): Subscribe to updates