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.
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.
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:
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:
you request to sell TSLA
broker submits order
exchange matches it
settlement happens (T+2, sometimes faster but still not instant)
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:
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.
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.
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.
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.
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! 🐴

