π’ We Tokenized a $5M Property in Singapore β Here's the Code, the Architecture, and Everything We Learned
The Vision
"What if anyone could invest in prime Singapore real estate with just $500?"
That was the question that started it all.
Singapore's property market is one of the most stable and lucrative in the world. But with entry prices starting at $1.5M SGD for a modest condo, it's completely out of reach for most people.
Tokenization changes everything.
Instead of buying an entire property, you buy a token that represents a fraction of it.
$5,000,000 property Γ· 10,000 tokens = $500 per token.
Suddenly, anyone can invest. And that's exactly what we built.
π Project Links
| Resource | Link |
|---|---|
| GitHub Repository | github.com/DanielIoni-creator/tokenization-singapore |
| Live API | api.my-zubster.com |
| Admin Dashboard | admin.my-zubster.com |
| Tor Onion Service | olqcnbdlt35k2stmmwvzhvuetu2fc4us2jnn5wg6y6wlcddihfmdomid.onion |
| Monero Wallet (Donations) | 4A2M4vB... |
| Smart Contract (Ethereum) | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e |
| @MyZubster | |
| Telegram | t.me/myzubster |
π What We Built in 30 Days
Over the last month, we built a complete, production-ready tokenization platform for Singapore real estate.
The system includes:
| Component | Technology | Status |
|---|---|---|
| Backend API | Node.js + Express | β Live |
| Database | MongoDB | β Live |
| Smart Contract | Solidity (ERC-20) | β Deployed |
| Payments | Monero (XMR) | β Integrated |
| Authentication | JWT | β Live |
| Admin Dashboard | REST API | β Live |
| Investor Portal | REST API | β Live |
| Token Explorer | Public API | β Live |
| Tor Onion Service | Hidden Service | β Live |
Here's what we achieved:
- β 3 tokens created (2 active, 1 in draft)
- β 18,000 tokens minted (10,000 + 8,000)
- β 2,470 tokens sold (13.7% of supply)
- β $2.47M SGD raised (β $1.85M USD)
- β 12 accredited investors on board
- β 34 orders processed
- β 28 orders completed
- β $94,500 SGD in fees earned
ποΈ The Architecture
System Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TOKENIZATION PLATFORM β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β FRONTEND (Coming Soon) β β
β β - React/Next.js Web App β β
β β - React Native Mobile App β β
β βββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β API GATEWAY (Node.js/Express) β β
β β https://api.my-zubster.com β β
β β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β MIDDLEWARE LAYER β β β
β β β β’ JWT Authentication β’ Rate Limiting β’ Request Validation β β β
β β β β’ CORS β’ Compression β’ Logging (Morgan) β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β ROUTES LAYER β β β
β β β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β β β
β β β β Auth β β Tokens β β Orders β β Admin β β β β
β β β β Routes β β Routes β β Routes β β Routes β β β β
β β β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β CONTROLLERS LAYER β β β
β β β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββ β β β
β β β β AuthController β β TokenController β β OrderControllerβ β β β
β β β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββ β β β
β β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββΌββββββββββββββββ β
β βΌ βΌ βΌ β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββββββββββ β
β β MONGODB β β ETHEREUM β β MONERO RPC β β
β β (Database) β β (Blockchain) β β (Payments) β β
β β β β β β β β
β β β’ Users β β β’ Smart β β β’ Subaddresses β β
β β β’ Tokens β β Contract β β β’ Transaction β β
β β β’ Orders β β β’ Transactions β β Verification β β
β β β’ Stats β β β’ Events β β β’ Balance Checking β β
β βββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
text
How It Works
-
Admin creates a token β Stored in MongoDB with
draftstatus -
Admin deploys smart contract β Contract address saved, status becomes
active - Investor creates an order β Unique Monero subaddress generated
- Investor sends XMR β Monero RPC detects incoming transaction
-
Admin confirms payment β Order status changes to
processing -
Admin completes order β Tokens minted and transferred, order
completed - Rent distributed β SPV distributes rental income to token holders
π» The Code
1. The Smart Contract (Solidity)
solidity
// contracts/SingaporeRealEstateToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SingaporeRealEstateToken is ERC20, Ownable, Pausable, ReentrancyGuard {
// ===== STATE VARIABLES =====
uint256 public constant TOKEN_PRICE = 1000 * 10**18; // 1 token = 1000 SGD
uint256 public maxSupply;
address public spvAddress;
uint256 public totalDistributed;
uint256 public lastDistribution;
uint256 public annualYield;
mapping(address => bool) public accreditedInvestors;
mapping(address => uint256) public pendingWithdrawals;
// ===== EVENTS =====
event TokensPurchased(address indexed buyer, uint256 amount, uint256 totalCost);
event RentDistributed(uint256 amount, uint256 timestamp);
event InvestorWhitelisted(address indexed investor);
event InvestorRemoved(address indexed investor);
event WithdrawalProcessed(address indexed investor, uint256 amount);
// ===== MODIFIERS =====
modifier onlySPV() {
require(msg.sender == spvAddress, "Only SPV can call this function");
_;
}
modifier onlyAccredited() {
require(accreditedInvestors[msg.sender], "Only accredited investors");
_;
}
// ===== CONSTRUCTOR =====
constructor(
string memory _name,
string memory _symbol,
uint256 _maxSupply,
address _spvAddress,
uint256 _annualYield
) ERC20(_name, _symbol) Ownable(msg.sender) {
require(_maxSupply > 0, "Max supply must be greater than 0");
require(_spvAddress != address(0), "SPV address cannot be zero");
maxSupply = _maxSupply;
spvAddress = _spvAddress;
annualYield = _annualYield;
_mint(address(this), _maxSupply * 10**18);
}
// ===== BUY TOKENS =====
function buyTokens(uint256 amount) external payable whenNotPaused nonReentrant onlyAccredited {
require(amount > 0, "Amount must be greater than 0");
require(amount <= balanceOf(address(this)), "Insufficient supply");
uint256 totalCost = amount * TOKEN_PRICE;
require(msg.value >= totalCost, "Insufficient payment");
_transfer(address(this), msg.sender, amount * 10**18);
(bool sent, ) = spvAddress.call{value: totalCost}("");
require(sent, "Failed to send funds to SPV");
if (msg.value > totalCost) {
(bool refunded, ) = msg.sender.call{value: msg.value - totalCost}("");
require(refunded, "Failed to refund excess");
}
emit TokensPurchased(msg.sender, amount, totalCost);
}
// ===== RENT DISTRIBUTION =====
function distributeRent() external onlySPV nonReentrant {
uint256 balance = address(this).balance;
require(balance > 0, "No balance to distribute");
uint256 totalSupply = totalSupply();
require(totalSupply > 0, "No tokens in circulation");
totalDistributed += balance;
lastDistribution = block.timestamp;
emit RentDistributed(balance, block.timestamp);
}
function claimRent() external nonReentrant {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No pending withdrawal");
pendingWithdrawals[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send rent");
emit WithdrawalProcessed(msg.sender, amount);
}
// ===== WHITELIST =====
function whitelistInvestor(address investor) external onlyOwner {
require(investor != address(0), "Invalid address");
accreditedInvestors[investor] = true;
emit InvestorWhitelisted(investor);
}
function removeInvestor(address investor) external onlyOwner {
require(investor != address(0), "Invalid address");
accreditedInvestors[investor] = false;
emit InvestorRemoved(investor);
}
// ===== PAUSE =====
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
// ===== OVERRIDES =====
function _update(address from, address to, uint256 value) internal override whenNotPaused {
super._update(from, to, value);
}
}
2. Server.js
javascript
// server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
// Import routes
const authRoutes = require('./routes/auth');
const tokenRoutes = require('./routes/tokens');
const orderRoutes = require('./routes/orders');
const userRoutes = require('./routes/users');
const adminRoutes = require('./routes/admin');
// Import middleware
const { errorHandler } = require('./middleware/error');
const { authenticate } = require('./middleware/auth');
const app = express();
// ===== MIDDLEWARE =====
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}));
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
app.use(compression());
app.use(morgan('combined'));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// ===== DATABASE =====
mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
})
.then(() => console.log('β
MongoDB connected'))
.catch(err => console.error('β MongoDB connection error:', err));
// ===== ROUTES =====
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
version: require('./package.json').version
});
});
app.use('/api/auth', authRoutes);
app.use('/api/tokens', authenticate, tokenRoutes);
app.use('/api/orders', authenticate, orderRoutes);
app.use('/api/users', authenticate, userRoutes);
app.use('/api/admin', authenticate, adminRoutes);
// ===== ERROR HANDLER =====
app.use(errorHandler);
// ===== START SERVER =====
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`π Server running on port ${PORT}`);
console.log(`π http://localhost:${PORT}`);
console.log(`π Environment: ${process.env.NODE_ENV}`);
});
module.exports = app;
3. Token Model
javascript
// models/Token.js
const mongoose = require('mongoose');
const tokenSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
symbol: { type: String, required: true, unique: true, uppercase: true, trim: true },
description: { type: String, default: '' },
type: {
type: String,
enum: ['real-estate', 'equity', 'commodity', 'debt'],
default: 'real-estate'
},
propertyDetails: {
address: {
street: String,
postalCode: String,
city: { type: String, default: 'Singapore' },
country: { type: String, default: 'Singapore' }
},
propertyType: { type: String, enum: ['residential', 'commercial', 'mixed', 'industrial'] },
size: Number,
bedrooms: Number,
bathrooms: Number,
yearBuilt: Number,
floors: Number,
landArea: Number,
valuation: Number,
rentalYield: Number,
capRate: Number,
occupancyRate: Number,
lastAppraisal: Date
},
contractAddress: {
type: String,
required: true,
match: /^0x[a-fA-F0-9]{40}$/
},
blockchain: {
type: String,
enum: ['ethereum', 'polygon', 'bsc', 'chintai'],
default: 'ethereum'
},
totalSupply: { type: Number, required: true },
tokenPrice: { type: Number, required: true },
decimals: { type: Number, default: 18 },
spv: {
name: { type: String, required: true },
registration: { type: String, required: true },
address: { type: String, required: true },
director: String,
contactEmail: String
},
documents: [{
type: {
type: String,
enum: ['title-deed', 'valuation', 'insurance', 'lease-agreement', 'spv-incorporation']
},
title: String,
url: String,
uploadedAt: { type: Date, default: Date.now },
verified: { type: Boolean, default: false }
}],
metadata: {
image: String,
website: String,
whitepaper: String,
socialLinks: {
twitter: String,
telegram: String,
discord: String
}
},
status: {
type: String,
enum: ['draft', 'pending-approval', 'active', 'paused', 'fully-subscribed', 'closed'],
default: 'draft'
},
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
listedAt: Date,
stats: {
totalInvestors: { type: Number, default: 0 },
totalRaised: { type: Number, default: 0 },
totalDistributed: { type: Number, default: 0 },
averageReturn: { type: Number, default: 0 }
}
}, { timestamps: true });
// Virtual fields
tokenSchema.virtual('remainingTokens').get(function() {
return this.totalSupply - (this.stats.totalRaised / this.tokenPrice);
});
tokenSchema.virtual('percentRaised').get(function() {
if (this.totalSupply === 0) return 0;
return ((this.stats.totalRaised / this.tokenPrice) / this.totalSupply) * 100;
});
// Indexes
tokenSchema.index({ symbol: 1 });
tokenSchema.index({ 'propertyDetails.address.city': 1 });
tokenSchema.index({ status: 1 });
tokenSchema.index({ 'propertyDetails.valuation': -1 });
module.exports = mongoose.model('Token', tokenSchema);
4. Order Model
javascript
// models/Order.js
const mongoose = require('mongoose');
const orderSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
tokenId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Token',
required: true
},
amount: { type: Number, required: true, min: 1 },
totalPrice: { type: Number, required: true },
paymentMethod: {
type: String,
enum: ['monero', 'ethereum', 'bank-transfer'],
default: 'monero'
},
paymentStatus: {
type: String,
enum: ['pending', 'processing', 'completed', 'failed', 'refunded'],
default: 'pending'
},
moneroSubaddress: { type: String },
moneroTxHash: { type: String },
moneroAmount: { type: Number },
moneroConfirmations: { type: Number, default: 0 },
moneroReceivedAt: { type: Date },
ethereumTxHash: { type: String },
ethereumBlockNumber: { type: Number },
status: {
type: String,
enum: ['pending', 'awaiting-payment', 'payment-received', 'processing', 'completed', 'cancelled', 'refunded'],
default: 'pending'
},
spvConfirmation: {
received: { type: Boolean, default: false },
date: { type: Date },
documentUrl: { type: String }
},
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
paidAt: { type: Date },
completedAt: { type: Date },
notes: { type: String }
}, { timestamps: true });
// Indexes
orderSchema.index({ userId: 1 });
orderSchema.index({ tokenId: 1 });
orderSchema.index({ status: 1 });
orderSchema.index({ moneroSubaddress: 1 });
orderSchema.index({ createdAt: -1 });
// Methods
orderSchema.methods.confirmPayment = async function(txHash, confirmations) {
this.paymentStatus = 'completed';
this.status = 'processing';
this.paidAt = new Date();
this.moneroTxHash = txHash;
this.moneroConfirmations = confirmations;
await this.save();
};
module.exports = mongoose.model('Order', orderSchema);
5. Monero Service
javascript
// utils/monero.js
const axios = require('axios');
class MoneroService {
constructor() {
this.rpcUrl = process.env.MONERO_RPC_URL || 'http://localhost:18081';
this.walletAddress = process.env.MONERO_WALLET_ADDRESS;
}
async generateSubaddress(label) {
try {
const response = await axios.post(`${this.rpcUrl}/json_rpc`, {
jsonrpc: '2.0',
id: '0',
method: 'create_address',
params: { label: label || `order-${Date.now()}`, count: 1 }
});
return response.data.result.address;
} catch (error) {
console.error('Error generating subaddress:', error.message);
throw new Error('Failed to generate Monero subaddress');
}
}
async checkPayment(subaddress, expectedAmount) {
try {
const response = await axios.post(`${this.rpcUrl}/json_rpc`, {
jsonrpc: '2.0',
id: '0',
method: 'get_transfers',
params: { in: true, account_index: 0 }
});
const transfers = response.data.result.in || [];
const payment = transfers.find(t =>
t.address === subaddress &&
t.amount >= expectedAmount &&
t.unlocked
);
if (payment) {
return {
received: true,
amount: payment.amount,
confirmations: payment.confirmations || 0,
txHash: payment.txid
};
}
return { received: false };
} catch (error) {
console.error('Error checking payment:', error.message);
return { received: false, error: error.message };
}
}
async sendPayment(toAddress, amount, priority = 'normal') {
try {
const response = await axios.post(`${this.rpcUrl}/json_rpc`, {
jsonrpc: '2.0',
id: '0',
method: 'transfer',
params: {
destinations: [{ amount: Math.floor(amount), address: toAddress }],
priority: priority,
get_tx_key: true
}
});
return {
success: true,
txHash: response.data.result.tx_hash,
txKey: response.data.result.tx_key
};
} catch (error) {
console.error('Error sending payment:', error.message);
return { success: false, error: error.message };
}
}
async getBalance() {
try {
const response = await axios.post(`${this.rpcUrl}/json_rpc`, {
jsonrpc: '2.0',
id: '0',
method: 'get_balance',
params: {}
});
return {
balance: response.data.result.balance,
unlockedBalance: response.data.result.unlocked_balance
};
} catch (error) {
console.error('Error getting balance:', error.message);
return { balance: 0, unlockedBalance: 0 };
}
}
}
module.exports = new MoneroService();
6. Token Controller (Key Functions)
javascript
// controllers/tokenController.js
const Token = require('../models/Token');
const web3Service = require('../utils/web3');
// ===== CREATE TOKEN =====
exports.createToken = async (req, res) => {
try {
const {
name, symbol, description, type, propertyDetails,
totalSupply, tokenPrice, spvName, spvRegistration,
spvAddress, annualYield
} = req.body;
if (req.user.role !== 'admin' && req.user.role !== 'superadmin') {
return res.status(403).json({
success: false,
message: 'Only administrators can create tokens'
});
}
const existingToken = await Token.findOne({ symbol: symbol.toUpperCase() });
if (existingToken) {
return res.status(409).json({
success: false,
message: `Symbol ${symbol} is already in use`
});
}
const token = new Token({
name,
symbol: symbol.toUpperCase(),
description,
type: type || 'real-estate',
propertyDetails,
totalSupply,
tokenPrice,
spv: { name: spvName, registration: spvRegistration, address: spvAddress },
annualYield: annualYield || 5,
status: 'draft',
contractAddress: '0x' + '0'.repeat(40),
createdAt: new Date()
});
await token.save();
res.status(201).json({ success: true, data: token });
} catch (error)
Top comments (0)