Skip to main content

Setup IndonesiaPropertyToken Project

🎯 Tujuan​

Setelah menyelesaikan bagian ini, Anda akan:

  • ✅ Membuat Foundry project untuk IndonesiaPropertyToken
  • ✅ Memahami arsitektur 2-contract system (KYCRegistry + PropertyToken)
  • ✅ Memahami konsep RWA (Real World Asset) tokenization
  • ✅ Compile contracts dan memeriksa output
  • ✅ Siap untuk menulis automated tests

🏗️ Setup Project​

Step 1: Buat Project Baru​

# Buat folder project
mkdir property-token-foundry
cd property-token-foundry

# Initialize Foundry project
forge init

Step 2: Hapus Sample Files​

# Hapus sample contract dan test
rm src/Counter.sol
rm test/Counter.t.sol
rm script/Counter.s.sol

Step 3: Update foundry.toml​

Edit file foundry.toml:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.30"

# Optimizer untuk gas efficiency
optimizer = true
optimizer_runs = 200

# Gas reports
gas_reports = ["*"]

# Verbosity untuk error messages
verbosity = 2

# Mantle Sepolia RPC
[rpc_endpoints]
mantle_sepolia = "https://rpc.sepolia.mantle.xyz"

# Etherscan untuk verification (Mantle explorer)
[etherscan]
mantle_sepolia = { key = "${ETHERSCAN_API_KEY}", url = "https://api-sepolia.mantlescan.xyz/api" }

📝 Smart Contracts​

IndonesiaPropertyToken menggunakan 2-contract system:

  1. KYCRegistry - Mengelola verifikasi KYC investor
  2. IndonesiaPropertyToken - ERC-20 token yang merepresentasikan kepemilikan properti

Buat KYCRegistry.sol​

Buat file src/KYCRegistry.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

/**
* @title KYCRegistry
* @notice Manages KYC verification for IndonesiaPropertyToken
* @dev Deploy this contract first, then use its address in PropertyToken
*
* Tutorial: https://docs.openzeppelin.com/contracts
*/
contract KYCRegistry {

// ============ STATE VARIABLES ============

address public admin;

// KYC Levels
enum KYCLevel {
NONE, // 0 - Belum KYC
BASIC, // 1 - KYC dasar (KTP)
VERIFIED, // 2 - KYC lengkap (KTP + NPWP)
ACCREDITED // 3 - Investor terakreditasi
}

struct Investor {
KYCLevel level;
uint256 expiryDate;
uint16 countryCode; // 360 = Indonesia
bool isActive;
}

// Mapping: wallet address => investor data
mapping(address => Investor) public investors;

// Total registered investors
uint256 public totalInvestors;

// ============ EVENTS ============

event InvestorRegistered(address indexed investor, KYCLevel level);
event InvestorUpdated(address indexed investor, KYCLevel newLevel);
event InvestorRevoked(address indexed investor);

// ============ MODIFIERS ============

modifier onlyAdmin() {
require(msg.sender == admin, "Only admin");
_;
}

// ============ CONSTRUCTOR ============

constructor() {
admin = msg.sender;
}

// ============ ADMIN FUNCTIONS ============

/**
* @notice Register new investor after KYC verification
* @param _investor Wallet address of investor
* @param _level KYC level (1-3)
* @param _countryCode Country code (360 for Indonesia)
* @param _validDays How many days KYC is valid
*/
function registerInvestor(
address _investor,
KYCLevel _level,
uint16 _countryCode,
uint256 _validDays
) external onlyAdmin {
require(_investor != address(0), "Invalid address");
require(_level != KYCLevel.NONE, "Invalid KYC level");
require(!investors[_investor].isActive, "Already registered");

investors[_investor] = Investor({
level: _level,
expiryDate: block.timestamp + (_validDays * 1 days),
countryCode: _countryCode,
isActive: true
});

totalInvestors++;
emit InvestorRegistered(_investor, _level);
}

/**
* @notice Update investor KYC level
*/
function updateInvestor(
address _investor,
KYCLevel _newLevel
) external onlyAdmin {
require(investors[_investor].isActive, "Not registered");
investors[_investor].level = _newLevel;
emit InvestorUpdated(_investor, _newLevel);
}

/**
* @notice Revoke investor KYC (blacklist)
*/
function revokeInvestor(address _investor) external onlyAdmin {
require(investors[_investor].isActive, "Not registered");
investors[_investor].isActive = false;
totalInvestors--;
emit InvestorRevoked(_investor);
}

// ============ VIEW FUNCTIONS ============

/**
* @notice Check if investor is verified and active
*/
function isVerified(address _investor) public view returns (bool) {
Investor memory inv = investors[_investor];

if (!inv.isActive) return false;
if (inv.level == KYCLevel.NONE) return false;
if (block.timestamp > inv.expiryDate) return false;

return true;
}

/**
* @notice Check if investor meets minimum KYC level
*/
function meetsLevel(
address _investor,
KYCLevel _requiredLevel
) public view returns (bool) {
if (!isVerified(_investor)) return false;
return uint8(investors[_investor].level) >= uint8(_requiredLevel);
}

/**
* @notice Get investor details
*/
function getInvestor(address _investor) external view returns (
KYCLevel level,
uint256 expiryDate,
uint16 countryCode,
bool isActive
) {
Investor memory inv = investors[_investor];
return (inv.level, inv.expiryDate, inv.countryCode, inv.isActive);
}
}

Buat IndonesiaPropertyToken.sol​

Buat file src/IndonesiaPropertyToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

/**
* @title IndonesiaPropertyToken
* @notice ERC-20 token representing fractional ownership of Indonesian real estate
* @dev Implements compliance checks via KYCRegistry
*
* Reference: https://www.erc3643.org/
*/
contract IndonesiaPropertyToken {

// ============ TOKEN METADATA ============

string public name;
string public symbol;
uint8 public constant decimals = 18;
uint256 public totalSupply;

// ============ PROPERTY INFO ============

struct PropertyInfo {
string propertyName; // "Apartemen Sudirman Tower"
string location; // "Jakarta Selatan"
uint256 totalValue; // Total property value in IDR
uint256 totalTokens; // Total tokens representing 100%
string legalDocument; // IPFS hash of legal docs
bool isActive;
}

PropertyInfo public property;

// ============ COMPLIANCE ============

address public admin;
address public kycRegistry; // KYCRegistry contract address

mapping(address => bool) public frozen; // Frozen accounts
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowances;

// Investment limits
uint256 public minInvestment = 1 ether; // Min 1 token
uint256 public maxInvestment = 1000 ether; // Max 1000 tokens

// ============ EVENTS ============

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event AccountFrozen(address indexed account, string reason);
event AccountUnfrozen(address indexed account);
event PropertyUpdated(string propertyName, uint256 totalValue);

// ============ MODIFIERS ============

modifier onlyAdmin() {
require(msg.sender == admin, "Only admin");
_;
}

modifier notFrozen(address _account) {
require(!frozen[_account], "Account is frozen");
_;
}

modifier onlyVerified(address _account) {
require(_isVerified(_account), "Not KYC verified");
_;
}

// ============ CONSTRUCTOR ============

/**
* @notice Deploy property token
* @param _name Token name (e.g., "Sudirman Tower Token")
* @param _symbol Token symbol (e.g., "SDMN")
* @param _kycRegistry Address of deployed KYCRegistry
* @param _propertyName Name of the property
* @param _location Property location
* @param _totalValue Total property value in IDR
* @param _totalTokens Total tokens to mint (representing 100%)
*/
constructor(
string memory _name,
string memory _symbol,
address _kycRegistry,
string memory _propertyName,
string memory _location,
uint256 _totalValue,
uint256 _totalTokens
) {
require(_kycRegistry != address(0), "Invalid KYC registry");

name = _name;
symbol = _symbol;
admin = msg.sender;
kycRegistry = _kycRegistry;

property = PropertyInfo({
propertyName: _propertyName,
location: _location,
totalValue: _totalValue,
totalTokens: _totalTokens,
legalDocument: "",
isActive: true
});

// Mint all tokens to admin initially
totalSupply = _totalTokens;
balances[msg.sender] = _totalTokens;
emit Transfer(address(0), msg.sender, _totalTokens);
}

// ============ ERC-20 FUNCTIONS ============

function balanceOf(address _owner) public view returns (uint256) {
return balances[_owner];
}

/**
* @notice Transfer tokens with compliance checks
* @dev Both sender and receiver must be KYC verified and not frozen
*/
function transfer(
address _to,
uint256 _value
)
public
notFrozen(msg.sender)
notFrozen(_to)
returns (bool)
{
require(_to != address(0), "Invalid recipient");
require(_isVerified(msg.sender), "Not KYC verified");
require(_isVerified(_to), "Not KYC verified");
require(balances[msg.sender] >= _value, "Insufficient balance");

// Check investment limits for receiver
uint256 newBalance = balances[_to] + _value;
require(newBalance <= maxInvestment, "Exceeds max investment");

balances[msg.sender] -= _value;
balances[_to] += _value;

emit Transfer(msg.sender, _to, _value);
return true;
}

function approve(address _spender, uint256 _value) public returns (bool) {
allowances[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}

function allowance(address _owner, address _spender) public view returns (uint256) {
return allowances[_owner][_spender];
}

function transferFrom(
address _from,
address _to,
uint256 _value
)
public
notFrozen(_from)
notFrozen(_to)
returns (bool)
{
require(_to != address(0), "Invalid recipient");
require(_isVerified(_from), "Not KYC verified");
require(_isVerified(_to), "Not KYC verified");
require(balances[_from] >= _value, "Insufficient balance");
require(allowances[_from][msg.sender] >= _value, "Insufficient allowance");

uint256 newBalance = balances[_to] + _value;
require(newBalance <= maxInvestment, "Exceeds max investment");

balances[_from] -= _value;
balances[_to] += _value;
allowances[_from][msg.sender] -= _value;

emit Transfer(_from, _to, _value);
return true;
}

// ============ ADMIN FUNCTIONS ============

/**
* @notice Freeze account (for AML/compliance)
*/
function freezeAccount(
address _account,
string calldata _reason
) external onlyAdmin {
frozen[_account] = true;
emit AccountFrozen(_account, _reason);
}

/**
* @notice Unfreeze account
*/
function unfreezeAccount(address _account) external onlyAdmin {
frozen[_account] = false;
emit AccountUnfrozen(_account);
}

/**
* @notice Force transfer (for legal compliance, recovery)
*/
function forceTransfer(
address _from,
address _to,
uint256 _value
) external onlyAdmin {
require(balances[_from] >= _value, "Insufficient balance");

balances[_from] -= _value;
balances[_to] += _value;

emit Transfer(_from, _to, _value);
}

/**
* @notice Update property legal documents
*/
function setLegalDocument(string calldata _ipfsHash) external onlyAdmin {
property.legalDocument = _ipfsHash;
}

/**
* @notice Update investment limits
*/
function setInvestmentLimits(
uint256 _min,
uint256 _max
) external onlyAdmin {
require(_min < _max, "Invalid limits");
minInvestment = _min;
maxInvestment = _max;
}

// ============ VIEW FUNCTIONS ============

/**
* @notice Get ownership percentage
*/
function getOwnershipPercent(address _owner) public view returns (uint256) {
if (totalSupply == 0) return 0;
return (balances[_owner] * 10000) / totalSupply; // Returns basis points (100% = 10000)
}

/**
* @notice Get token value in IDR
*/
function getTokenValueIDR() public view returns (uint256) {
if (property.totalTokens == 0) return 0;
return property.totalValue / (property.totalTokens / 1 ether);
}

/**
* @notice Check if transfer would be allowed
*/
function canTransfer(
address _from,
address _to,
uint256 _value
) public view returns (bool, string memory) {
if (frozen[_from]) return (false, "Sender is frozen");
if (frozen[_to]) return (false, "Receiver is frozen");
if (!_isVerified(_from)) return (false, "Sender not KYC verified");
if (!_isVerified(_to)) return (false, "Receiver not KYC verified");
if (balances[_from] < _value) return (false, "Insufficient balance");
if (balances[_to] + _value > maxInvestment) return (false, "Exceeds max investment");

return (true, "Transfer allowed");
}

// ============ INTERNAL FUNCTIONS ============

function _isVerified(address _account) internal view returns (bool) {
// Admin is always verified
if (_account == admin) return true;

// Check KYC registry
(bool success, bytes memory data) = kycRegistry.staticcall(
abi.encodeWithSignature("isVerified(address)", _account)
);

if (!success) return false;
return abi.decode(data, (bool));
}
}

💡 Memahami Arsitektur Contract​

Kenapa 2-Contract System?​

┌─────────────────────────────────────────────────────────────────┐
│ RWA Token Architecture │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────┐ ┌─────────────────────────────────┐
│ KYCRegistry │◄──────────│ IndonesiaPropertyToken │
│ │ calls │ │
│ • Register │ isVerified() │
│ • Update │ │ • ERC-20 standard │
│ • Revoke │ │ • Compliance checks │
│ • isVerified() │ │ • Investment limits │
│ • meetsLevel() │ │ • Freeze/Unfreeze │
└─────────────────┘ └─────────────────────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────────────────────┐
│ Investor DB │ │ Property Data │
│ │ │ │
│ • KYC Level │ │ • Property Name │
│ • Country Code │ │ • Location │
│ • Expiry Date │ │ • Total Value (IDR) │
│ • Active Status │ │ • Legal Documents (IPFS) │
└─────────────────┘ └─────────────────────────────────┘

Keuntungan:

  1. Separation of Concerns - KYC logic terpisah dari token logic
  2. Reusable - 1 KYCRegistry bisa dipakai multiple property tokens
  3. Upgradeable - Bisa ganti KYCRegistry tanpa redeploy token
  4. Compliance - Memenuhi standar ERC-3643 untuk security tokens

Flow Diagram​

┌─────────────────────────────────────────────────────────────────┐
│ IndonesiaPropertyToken Flow │
└─────────────────────────────────────────────────────────────────┘

ADMIN INVESTOR
│ │
│ 1. Deploy KYCRegistry │
▼ │
┌────────────────┐ │
│ KYCRegistry │ │
└────────────────┘ │
│ │
│ 2. Deploy PropertyToken │
▼ │
┌────────────────────────┐ │
│ IndonesiaPropertyToken │ │
│ (Admin gets 100% tokens) │
└────────────────────────┘ │
│ │
│ 3. registerInvestor() │
│ (KYC verification) │
▼ ▼
┌────────────────┐ ┌───────────────┐
│ KYC VERIFIED │────────────────────►│ INVESTOR │
└────────────────┘ │ (KYC Level 1+) │
│ └───────────────┘
│ 4. transfer() tokens │
│ to verified investors │
▼ ▼
┌────────────────────────────────────────────────────┐
│ Verified investors can: │
│ • Receive tokens │
│ • Transfer to other verified investors │
│ • Check ownership percentage │
│ • View token value in IDR │
└────────────────────────────────────────────────────┘
│
│ Admin can:
├─── freezeAccount() ─── Block suspicious activity
├─── forceTransfer() ─── Legal compliance/recovery
└─── setInvestmentLimits() ─── Update min/max

KYC Levels​

LevelKodeNamaPersyaratanLimit
0NONEBelum KYC-Tidak bisa transfer
1BASICKYC DasarKTPTransfer standar
2VERIFIEDKYC LengkapKTP + NPWPTransfer lebih besar
3ACCREDITEDInvestor TerakreditasiKTP + NPWP + Bukti AsetUnlimited

Compliance Features​

FeatureDeskripsiFungsi Terkait
KYC CheckVerifikasi identitas investor_isVerified()
Freeze AccountBlokir akun mencurigakan (AML)freezeAccount()
Force TransferTransfer paksa untuk legal complianceforceTransfer()
Investment LimitsBatasan investasi per akunminInvestment, maxInvestment
Transfer ValidationCek sebelum transfercanTransfer()

Perhitungan Kepemilikan​

// Ownership dalam basis points (100% = 10000)
// Contoh: 1000 tokens dari 10000 total = 10% ownership

function getOwnershipPercent(address _owner) public view returns (uint256) {
return (balances[_owner] * 10000) / totalSupply;
}

// Contoh:
// balances[investor] = 1000 ether (1000 tokens)
// totalSupply = 10000 ether (10000 tokens)
// ownershipPercent = (1000 * 10000) / 10000 = 1000 basis points = 10%

Perhitungan Nilai Token​

// Contoh Properti:
// propertyName: "Apartemen Sudirman Tower"
// totalValue: 50_000_000_000 (Rp 50 Miliar)
// totalTokens: 10000 ether (10,000 tokens)

function getTokenValueIDR() public view returns (uint256) {
return property.totalValue / property.totalTokens;
}

// tokenValueIDR = 50_000_000_000 / 10000 = 5_000_000 (Rp 5 Juta per token)

🔨 Compile Contracts​

Build​

forge build

Output yang diharapkan:

[⠊] Compiling...
[⠒] Compiling 2 files with Solc 0.8.30
[⠢] Solc 0.8.30 finished in 1.45s
Compiler run successful!

Check Contract Sizes​

forge build --sizes

Output:

| Contract                | Size (KB) | Margin (KB) |
|-------------------------|-----------|-------------|
| KYCRegistry | 2.156 | 22.420 |
| IndonesiaPropertyToken | 4.892 | 19.684 |

Size limit: 24.576 KB (EIP-170)

  • KYCRegistry: ~2.2 KB ✅ (9% of limit)
  • IndonesiaPropertyToken: ~4.9 KB ✅ (20% of limit)

Inspect ABI​

# KYCRegistry ABI
forge inspect KYCRegistry abi | head -50

# PropertyToken ABI
forge inspect IndonesiaPropertyToken abi | head -50

📊 Struktur File Project​

Setelah setup, struktur project Anda:

property-token-foundry/
├── lib/
│ └── forge-std/ # Foundry standard library
├── script/
│ └── (kosong - akan dibuat di Part 4)
├── src/
│ ├── KYCRegistry.sol # ✅ Contract KYC
│ └── IndonesiaPropertyToken.sol # ✅ Contract Token
├── test/
│ └── (kosong - akan dibuat di Part 3)
├── .gitignore
├── foundry.toml # ✅ Config updated
└── README.md

🎯 Memahami Functions​

KYCRegistry Functions​

Admin Functions​

FunctionDeskripsiAccess
registerInvestor(address, level, countryCode, validDays)Daftarkan investor baruAdmin
updateInvestor(address, newLevel)Update KYC level investorAdmin
revokeInvestor(address)Cabut KYC (blacklist)Admin

View Functions​

FunctionDeskripsiReturns
isVerified(address)Cek apakah investor verified & activebool
meetsLevel(address, level)Cek apakah memenuhi minimum levelbool
getInvestor(address)Ambil detail investor(level, expiry, country, active)

IndonesiaPropertyToken Functions​

ERC-20 Standard​

FunctionDeskripsiCompliance
transfer(to, value)Transfer tokensKYC + Not Frozen
transferFrom(from, to, value)Transfer dengan approvalKYC + Not Frozen
approve(spender, value)Approve spender-
allowance(owner, spender)Cek allowance-
balanceOf(owner)Cek balance-

Admin Functions​

FunctionDeskripsiUse Case
freezeAccount(account, reason)Freeze akunAML/Suspicious activity
unfreezeAccount(account)Unfreeze akunClear investigation
forceTransfer(from, to, value)Transfer paksaLegal compliance
setLegalDocument(ipfsHash)Update dokumen legalNew legal docs
setInvestmentLimits(min, max)Update investment limitsRegulatory change

View Functions​

FunctionDeskripsiReturns
getOwnershipPercent(owner)Persentase kepemilikanuint256 (basis points)
getTokenValueIDR()Nilai per token dalam IDRuint256
canTransfer(from, to, value)Cek apakah transfer allowed(bool, string)

💡 Key Concepts Review​

1. External Contract Calls​

// Low-level call ke KYCRegistry
function _isVerified(address _account) internal view returns (bool) {
// staticcall = read-only call (tidak bisa modify state)
(bool success, bytes memory data) = kycRegistry.staticcall(
abi.encodeWithSignature("isVerified(address)", _account)
);

if (!success) return false;
return abi.decode(data, (bool));
}

Kenapa pakai staticcall?

  • Security: Mencegah reentrancy attack
  • Gas efficient: View function tidak butuh write access
  • Safe: Jika KYCRegistry error, tidak affect token contract

2. Multiple Modifiers​

function transfer(address _to, uint256 _value)
public
notFrozen(msg.sender) // 1. Cek sender tidak frozen
notFrozen(_to) // 2. Cek receiver tidak frozen
onlyVerified(msg.sender) // 3. Cek sender KYC verified
onlyVerified(_to) // 4. Cek receiver KYC verified
returns (bool)
{
// Semua modifier harus pass sebelum masuk sini
}

3. Compliance-First Design​

// Check before transfer
function canTransfer(address _from, address _to, uint256 _value)
public view returns (bool, string memory)
{
if (frozen[_from]) return (false, "Sender is frozen");
if (frozen[_to]) return (false, "Receiver is frozen");
if (!_isVerified(_from)) return (false, "Sender not KYC verified");
if (!_isVerified(_to)) return (false, "Receiver not KYC verified");
if (balances[_from] < _value) return (false, "Insufficient balance");
if (balances[_to] + _value > maxInvestment) return (false, "Exceeds max investment");

return (true, "Transfer allowed");
}

Use case: Frontend bisa check dulu sebelum user submit transaction.

4. Struct untuk Property Info​

struct PropertyInfo {
string propertyName; // Nama properti
string location; // Lokasi
uint256 totalValue; // Nilai total (IDR)
uint256 totalTokens; // Total token = 100%
string legalDocument; // IPFS hash dokumen legal
bool isActive; // Status aktif
}

PropertyInfo public property;

✅ Checklist Sebelum Lanjut​

Sebelum melanjutkan ke testing, pastikan:

  • Project property-token-foundry sudah dibuat
  • KYCRegistry.sol ada di folder src/
  • IndonesiaPropertyToken.sol ada di folder src/
  • forge build berhasil tanpa error
  • Memahami 2-contract system (KYCRegistry + PropertyToken)
  • Memahami KYC levels (NONE → BASIC → VERIFIED → ACCREDITED)
  • Memahami compliance features (freeze, force transfer, limits)
  • Memahami perhitungan ownership dan token value

🎓 Ringkasan​

Dalam modul ini, Anda telah mempelajari:

  1. Setup Foundry Project untuk RWA token development
  2. 2-Contract Architecture - memisahkan KYC logic dari token logic
  3. KYCRegistry Contract - mengelola verifikasi investor
  4. IndonesiaPropertyToken Contract - ERC-20 dengan compliance built-in
  5. Compliance Features - freeze, force transfer, investment limits

Selamat! Anda sekarang memiliki foundation yang kuat untuk membangun RWA tokens yang compliant dengan regulasi.


Build compliant RWA tokens! 🏠🪙