Cover photo

Part 2 Tutorial: Tokenizing Using ERC-3643

In Part 1, I showed you something most RWA demos conveniently avoid.

We didn’t start with minting until we have an identity registry. We separated compliance from token logic. We made sure the transfers weren’t just balance checks, but decisions based on who is allowed to participate. At that point, your token stopped being a toy.

But everything we’ve built so far is still perfectly self-contained. Smart contracts can't make HTTP requests. They can't check your brokerage account. They can't know if you, as the RWA token issuer actually own the assets.

So now we introduce the part everyone loves to oversimplify: oracles.

Step 3 Oracle Layer (Reality Exists Outside)

Not all oracles are equal. And more importantly, you will almost always need more than one. Because you’re not just asking one question. You’re asking many:

  • what is the current price of this asset?

  • is this token actually backed?

  • did something happen offchain that should update state onchain?

  • has a redemption request been fulfilled?

Each of these will need information from offchain. Let's put our price oracle and proof of reserve oracle now:

// src/oracle/interfaces/IPriceOracle.sol
interface IPriceOracle {
    function latestRoundData() external view returns (
        uint80 roundId,
        int256 answer,      // Price with 8 decimals
        uint256 startedAt,
        uint256 updatedAt,
        uint80 answeredInRound
    );
}


// src/oracle/interfaces/IAttestationOracle.sol
interface IAttestationOracle {
    function hasReserves(string memory asset, uint256 amount)
        external view returns (bool);
}

As an example, I'm using Chainlink for both price feed and Proof of Reserve attestation

Again, separation of concerns: Oracles should be mere data providers, if you must enforce logic for transfers and other things, do them on a separate contract.

Moving on...

Your data should also have an expiration date. If the oracle hasn't updated in 3 hours, the price might be wrong. Using stale data can lead to:

  • Minting tokens at wrong price (loss for protocol)

  • Redemptions at wrong price (loss for users)

  • Arbitrage (loss for everyone T_T)

So, it's better to revert than use stale data.

Let's create a utility to do the staleness check on our price feed:

// src/oracle/libraries/OracleLib.sol
library OracleLib {
    uint256 private constant TIMEOUT = 3 hours;
   
    function staleCheckLatestRoundData(IPriceOracle priceFeed)
        internal view returns (uint80, int256, uint256, uint256, uint80)
    {
        (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) =
            priceFeed.latestRoundData();
       
        // Check for stale price
        uint256 secondsSince = block.timestamp - updatedAt;
        if (secondsSince > TIMEOUT) {
            revert OracleLib__StalePrice();
        }
       
        // Check for invalid price
        if (answer <= 0) {
            revert OracleLib__InvalidPrice();
        }
       
        return (roundId, answer, startedAt, updatedAt, answeredInRound);
    }
}

Great! Now there are still other things you need an oracle for. For example, making sure that the system knows whether a token redemption request has been fullfilled in the physical world.

You can find the sample code for other oracles in my repo.

Step 4 Stock Token (The Orchestrator)

Finally, this is where we bring it all together. We'll use a token with ERC20 mechanics to represent our stock, but with additional rules:

// src/token/StockToken.sol
contract StockToken is ERC20, AccessControl {
    IIdentityRegistry private _identityRegistry;
    IModularCompliance private _compliance;
    IAttestationOracle private _attestationOracle;
    bool private _requireAttestation;
   
    function transfer(address to, uint256 amount)
        public override whenNotPaused returns (bool)
    {
        address from = msg.sender;
       
        // 1. Check freezing
        require(!_frozen[from] && !_frozen[to], "address frozen");
       
        // 2. Check unfrozen balance
        require(balanceOf(from) - _frozenTokens[from] >= amount, "insufficient unfrozen balance");
       
        // 3. Check identity verification
        require(_identityRegistry.isVerified(to), "receiver not verified");
       
        // 4. Check compliance
        require(_compliance.canTransfer(from, to, amount), "not compliant");
       
        // 5. Execute transfer
        _transfer(from, to, amount);
       
        // 6. Notify compliance (for state updates)
        _compliance.transferred(from, to, amount);
       
        return true;
    }
   
    function mint(address to, uint256 amount) public onlyAgent {
        require(_identityRegistry.isVerified(to), "receiver not verified");
        require(_compliance.canTransfer(address(0), to, amount), "mint not compliant");
       
        // Optional: Verify backing via attestation oracle
        if (_requireAttestation && address(_attestationOracle) != address(0)) {
            require(
                _attestationOracle.hasReserves("STOCK", amount),
                "insufficient reserves"
            );
        }
       
        _mint(to, amount);
        _compliance.created(to, amount);
    }
}

Now you can see that our StockToken does a couple of things that are not normally part of an ERC20 behavior:

  • it delegates verification to IdentityRegistry

  • it delegates rules to Compliance

  • integrates data from offchain through oracles (disabled by default for this tutorial)

Notice that transfer cannot happen if the address of msg.sender or to is not part of the verified and compliant list.

If you want to check, clone our repo and run forge test --match-contract StockTokenTest to verify transfers are restricted.

What if the regulators required wallet freezing?

In some scenarios, you might be required to add a freezing and force transfer function in case of things like court orders, violations, or suspicious activities... Here's how you do it:

mapping(address => bool) private _frozen;
mapping(address => uint256) private _frozenTokens;


function setAddressFrozen(address wallet, bool freeze) public onlyAgent {
    _frozen[wallet] = freeze;
    emit AddressFrozen(wallet, freeze, msg.sender);
}


function freezePartialTokens(address wallet, uint256 amount) external onlyAgent {
    _frozenTokens[wallet] += amount;
    emit TokensFrozen(wallet, amount);
}

function forcedTransfer(address from, address to, uint256 amount)
    public onlyAgent returns (bool)
{
    require(balanceOf(from) >= amount, "insufficient balance");
    require(_identityRegistry.isVerified(to), "receiver not verified");
   
    // Unfreeze tokens if needed
    uint256 freeBalance = balanceOf(from) - _frozenTokens[from];
    if (amount > freeBalance) {
        uint256 tokensToUnfreeze = amount - freeBalance;
        _frozenTokens[from] -= tokensToUnfreeze;
    }
   
    // Execute transfer (bypass compliance for forced transfers)
    _transfer(from, to, amount);
    _compliance.transferred(from, to, amount);
   
    return true;
}

In danki code, forced transfers bypass compliance checks but still verify receiver identity. This is intentional, court orders don't care about daily transfer limits.

Yaiy, great. We're down to the final and most important piece:

Step 5 Redemption Flow

There's one little problem when redeeming a stock token: blockchains are synchronous. Stock markets are asynchronous. You can't sell a stock in the same transaction you burn the token.

In the blockchain:

  • everything inside a transaction happens immediately and atomically

  • either the whole thing succeeds, or it fails

  • there is no waiting, no callbacks, no “come back later”

But you can't do that in real markets. In real stock trading, everything is delayed, queued, and processed externally:

  1. you request to sell TSLA

  2. broker submits order

  3. exchange matches it

  4. settlement happens (T+2, sometimes faster but still not instant)

  5. cash is credited

This can take seconds to execute and sometimes days to settle.

Solving this separates real RWA from a cosplaying ERC20. And I'll tell you how.

We create a 3-step async redemption process. The solution is a bit long this time, and please take note that THIS IS NOT MY PRODUCTION CODE, but here we go:

// src/redemption/RedemptionManager.sol
contract RedemptionManager is AccessControl, ReentrancyGuard {
    bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
   
    struct RedemptionRequest {
        address user;
        uint256 tokenAmount;
        uint256 expectedUsdc;
        uint256 timestamp;
        bool fulfilled;
    }
   
    mapping(bytes32 => RedemptionRequest) private s_redemptionRequests;
    mapping(address => uint256) private s_withdrawalBalance;
    mapping(address => bytes32[]) private s_userRequests; // Track user's requests
   
    // Step 1: User requests redemption
    function requestRedemption(uint256 tokenAmount)
        external nonReentrant returns (bytes32 requestId)
    {
        // Calculate expected USDC
        uint256 stockPrice = i_stockPriceOracle.getPrice();
        uint256 usdcPrice = i_usdcPriceOracle.getPrice();
        uint256 expectedUsdc = (tokenAmount * stockPrice) / usdcPrice;
       
        // Generate unique request ID
        requestId = keccak256(abi.encodePacked(
            msg.sender,
            tokenAmount,
            block.timestamp,
            s_requestCounter++
        ));
       
        // Store request
        s_redemptionRequests[requestId] = RedemptionRequest({
            user: msg.sender,
            tokenAmount: tokenAmount,
            expectedUsdc: expectedUsdc,
            timestamp: block.timestamp,
            fulfilled: false
        });
       
        // Track for user
        s_userRequests[msg.sender].push(requestId);
       
        // Burn tokens immediately (prevents double-spending)
        i_stockToken.burn(msg.sender, tokenAmount);
       
        emit RedemptionRequested(requestId, msg.sender, tokenAmount, expectedUsdc);
    }
   
    // Step 2: Oracle finalizes after selling stock off-chain
    function finalizeRedemption(bytes32 requestId, uint256 usdcAmount)
        external onlyRole(ORACLE_ROLE) nonReentrant
    {
        RedemptionRequest storage request = s_redemptionRequests[requestId];
       
        require(request.user != address(0), "request not found");
        require(!request.fulfilled, "already fulfilled");
        require(!isRequestExpired(requestId), "request expired");
       
        // Check slippage (allow up to 2% deviation)
        uint256 minAcceptable = (request.expectedUsdc * 98) / 100;
        require(usdcAmount >= minAcceptable, "slippage exceeded");
       
        // CRITICAL: Verify contract has sufficient USDC
        uint256 contractBalance = i_usdc.balanceOf(address(this));
        require(contractBalance >= usdcAmount, "insufficient contract balance");
       
        // Mark as fulfilled
        request.fulfilled = true;
       
        // Credit user's withdrawal balance
        s_withdrawalBalance[request.user] += usdcAmount;
       
        emit RedemptionFulfilled(requestId, request.user, usdcAmount);
    }
   
    // Step 3: User withdraws USDC
    function withdraw() external nonReentrant returns (uint256 amount) {
        amount = s_withdrawalBalance[msg.sender];
        require(amount > 0, "no balance");
       
        // Clear balance before transfer (CEI pattern)
        s_withdrawalBalance[msg.sender] = 0;
       
        // Transfer USDC (with rounding up to favor user)
        uint256 usdcAmount = (amount + 1e12 - 1) / 1e12; // 18 decimals → 6 decimals
        require(i_usdc.transfer(msg.sender, usdcAmount), "transfer failed");
       
        emit Withdrawn(msg.sender, amount);
    }
   
    // Helper: Get user's pending requests
    function getPendingRequests(address user)
        external view returns (bytes32[] memory)
    {
        bytes32[] memory allRequests = s_userRequests[user];
        uint256 pendingCount = 0;
       
        // Count pending
        for (uint256 i = 0; i < allRequests.length; i++) {
            if (!s_redemptionRequests[allRequests[i]].fulfilled) {
                pendingCount++;
            }
        }
       
        // Build array
        bytes32[] memory pending = new bytes32[](pendingCount);
        uint256 index = 0;
        for (uint256 i = 0; i < allRequests.length; i++) {
            if (!s_redemptionRequests[allRequests[i]].fulfilled) {
                pending[index++] = allRequests[i];
            }
        }
       
        return pending;
    }
}

Redemption here happens in 3 steps:

  1. The user requests redemption. We estimate the USDC using oracle prices, store the request, and burn the tokens immediately. Burn first, ask questions later. No double-spending or pretending you still hold the asset.

  2. The oracle finalizes after the stock is actually sold offchain. This is the part people like to handwave. The chain can’t wait for brokers or settlement, so we don’t fake it. Offchain happens first, then the oracle comes back and settles it onchain. We check slippage and make sure the money is actually there.

  3. The user withdraws. For security reasons, we don’t push funds automatically... we credit a balance and let them pull. Great, fewer ways to get rekt.

That’s async redemption for you. You can burn a token instantly but you cannot sell a stock instantly.

A few more notes on this design: oracle pays for finalization and user pays for withdrawal, plus this accumulate multiple redemptions in batches for gas efficiency. Also, if user loses their requestId, they can't cancel expired requests. This design track all requests per user so they can always query their pending redemptions.

Step 6 Offchain Integrations

Unfortunately with RWA, the backend is part of your system.

Smart contracts are great at enforcing rules. They're terrible at making HTTP requests, communicating with brokerage accounts, or knowing if you actually sold the stock.

So you need an off-chain service that listens for on-chain events, executes real-world actions (sell stock, buy USDC), and reports them back to the chain.

This is not optional. This is the entire point of RWA.

The only catch is that your backend is now a trusted party. Which means if it lies, breaks, or gets owned, your redemption flow goes with it. Audit accordingly.

Let's make an event listener:

// Backend service listening for redemption events
contract.on("RedemptionRequested", async (requestId, user, tokenAmount, expectedUsdc) => {
    console.log(`Redemption requested: ${requestId}`);
   
    // 1. Sell stock on brokerage (Alpaca, Interactive Brokers, etc.)
    const order = await brokerage.sellStock("AAPL", tokenAmount);
    await order.waitForExecution(); // Wait for T+2 settlement
   
    // 2. Receive USD proceeds
    const proceeds = order.filledValue;
    console.log(`Sold for $${proceeds}`);
   
    // 3. Convert to USDC (via DEX or OTC)
    const usdc = await convertToUsdc(proceeds);
   
    // 4. Send USDC to RedemptionManager
    await usdcContract.transfer(redemptionManager.address, usdc);
   
    // 5. Finalize redemption on-chain
    await redemptionManager.finalizeRedemption(requestId, usdc);
   
    console.log(`Redemption finalized: ${requestId}`);
});

Again, not production code. In real life, you will integrate this using a real oracle, Ethers and Alpaca. But this should give you a good overview of how it looks like.

This is where the "RWA" part happens. The smart contract is just the on-chain interface. The real work (selling stocks, converting to USDC) happens off-chain. It's unavoidable. And this is why RWA is hard. Not because of Solidity. Because of integrating blockchain with traditional finance.

At this point, we’ve crossed the boundary.

Our self-contained smart contract system in Part 1 is now something else entirely.

We’ve:

  • introduced external data through oracles

  • modeled asynchronous redemption instead of pretending everything settles instantly

  • and came to acceptance that offchain systems are part of the architecture

In Part 3, we’ll go through everything this code is still missing if you want to use it in production.

We’ll cover:

  • common RWA security vulnerabilities and quick prevention advice

  • what you actually need before calling this “production-ready”

  • and the migration path to implementing this with Philippine assets

Because honestly you can make these contracts compile in an afternoon. But you'll spend the rest of your time making sure it doesn’t break in the real world.

See you on the final round! 🐴