3 production-ready Solidity templates for regulated tokenization
Open-sourced three Solidity templates we use across ICO and real-estate tokenization platforms: ERC-20 with cliff and linear vesting, M-of-N multi-signature custodial vault, and ERC-721 with EIP-2981 royalties.
3 production-ready Solidity templates for regulated tokenization
TL;DR: After shipping ICO, real-estate, and mineral-mining tokenization platforms, we kept rewriting the same three contracts: an ERC-20 with cliff + linear vesting, a multi-signature custodial vault, and an ERC-721 with EIP-2981 royalties. We open-sourced them as DualNova/tokenization-templates — Solidity 0.8.24, OpenZeppelin 5.1, Hardhat 2.22, 25 unit tests, MIT. The repo is not yet audited by a third party; it's a template to fork, customize, and audit before mainnet, not a drop-in production deploy.
Why these three contracts
Every tokenization platform we've shipped to production has needed the same three primitives, regardless of vertical:
- Vesting — for team grants, advisor allocations, and seed-investor unlocks. Always with a cliff. Always with the ability to revoke (employees leave; advisors don't deliver).
- Custody — for the treasury that holds the proceeds, the smart-contract owner key, and any operational reserves. Always multi-signature; in regulated jurisdictions, always documented for the lawyers.
- NFT with royalties — for fractional real-estate certificates, brand-collaboration drops, and any RWA scenario where each token represents a unique off-chain asset and the issuer wants a cut of secondary-market sales.
We've written variants of these contracts in five client engagements. Each time, the differences were small (different token name, different role list, different royalty rate); each time, the cleanup-and-test work was disproportionate.
So we cleaned them up once for ourselves, hardened them, wrote a focused test suite, and shipped them as MIT templates. This post is the long version of what's in each contract and the design decisions that survived production.
Honest disclaimers up front
These contracts have not been audited by a qualified third-party firm. They have been written carefully, internally reviewed, and they have unit-test coverage of both happy and rejection paths — but a unit test is not an audit. The recommended path is:
- Fork the repo.
- Customize the constructor parameters and any business-specific behavior.
- Run the test suite and add tests for your customizations.
- Have the result audited by a qualified smart-contract security firm.
- Deploy to a testnet, simulate the full lifecycle, and only then push to mainnet.
DualNova has working relationships with several audit firms in LATAM and the US; we're happy to introduce you. Email [email protected] before opening any public issue about a vulnerability you find.
With that said — into the contracts.
1. ERC20Vesting.sol
The standard pattern for distributing tokens to a team, advisors, and seed investors over time. One vesting schedule per beneficiary; new schedules for the same address revert (use a separate multisig per beneficiary if you need multiple grants).
new ERC20Vesting("Acme Token", "ACME", 1_000_000e18, multisigOwner);
// Grant 100k tokens vesting over 4 years with a 1-year cliff
token.grant(
beneficiary,
100_000e18,
uint64(block.timestamp),
365 days, // cliff
4 * 365 days // duration
);
Cliff semantics
The cliff is a hard wall. Nothing is claimable before start + cliffSeconds. At the cliff, the full cliff portion becomes claimable atomically (so a 1-year cliff out of a 4-year vest unlocks exactly 25% at the moment the cliff elapses). After the cliff, vesting is linear to the end.
function vestedAmount(address beneficiary) public view returns (uint128) {
Schedule memory s = schedules[beneficiary];
if (s.total == 0) return 0;
if (s.revoked) return s.total;
if (block.timestamp < s.start + s.cliffSeconds) return 0;
if (block.timestamp >= s.start + s.durationSeconds) return s.total;
uint256 elapsed = block.timestamp - s.start;
return uint128((uint256(s.total) * elapsed) / s.durationSeconds);
}
The if (s.revoked) return s.total branch is the only non-obvious line. It's there because revoke caps the schedule's total at the vested-at-revoke amount, freezing the schedule — without this branch, the linear formula would continue computing as if vesting still progressed.
Revoke semantics
Owner can revoke (e.g. for terminated employees). The unvested portion at the moment of revocation returns to the owner; the already-vested portion remains claimable by the beneficiary. This is the standard team-grant pattern from the YC handbook.
function revoke(address beneficiary) external onlyOwner {
Schedule storage s = schedules[beneficiary];
if (s.total == 0) revert NoScheduleFound(beneficiary);
if (s.revoked) revert AlreadyRevoked(beneficiary);
uint128 vested = vestedAmount(beneficiary);
uint128 unvested = s.total - vested;
s.total = vested; // freeze schedule
s.revoked = true;
if (unvested > 0) {
_transfer(address(this), owner(), unvested);
}
emit ScheduleRevoked(beneficiary, unvested);
}
Things this contract intentionally does not support:
- Modifying an existing schedule. To change a grant, revoke and re-grant. This is deliberate — silent edits are a footgun and an audit nightmare.
- Multiple schedules per address. Use separate addresses (e.g. one multisig per beneficiary).
- Beneficiary-initiated revocation. Only the owner.
- Anyone can call
release(beneficiary)to push the vested tokens to the beneficiary. The tokens always go to the beneficiary regardless of caller — useful for keepers and gas-sponsored claims.
Test coverage
Ten tests covering: initial mint, invalid params, schedule grant, double-grant rejection, cliff boundary, linear progression, release, revoke (with owner refund + beneficiary cleanup), double-revoke rejection, non-owner authorization. All pass on Solidity 0.8.24 with viaIR off.
2. MultiSigVault.sol
Minimal M-of-N multi-signature vault for ETH and ERC-20 custody. Designed for institutional flows where 2-of-3 or 3-of-5 signers approve withdrawals on a per-transaction basis.
// 2-of-3 vault
new MultiSigVault([alice, bob, carol], 2);
// Anyone can fund (`receive() {}`)
// An owner submits a withdrawal:
uint256 txId = vault.submit(recipient, 1 ether, "");
// A second owner confirms:
vault.confirm(txId);
// Any owner executes once the threshold is met:
vault.execute(txId);
Why we wrote our own instead of using Gnosis Safe
Gnosis Safe is excellent. It's also a huge dependency surface for clients who just need a 3-of-5 wallet for an ICO treasury. The Safe contracts are upgradeable proxies, the deployment toolchain is involved, and the audit trail is dense.
For 80% of our tokenization clients, what they actually need is: "a contract address, three signer addresses, a threshold of two, and a tab where signers can see pending transactions." MultiSigVault is 150 lines of Solidity, deploys in a single transaction, and has zero proxy layers. For clients who outgrow it later, the migration to Gnosis Safe is a single multisig-approved transaction.
Owner mutation via self-call
Adding, removing, or replacing owners is done by submitting a transaction to the vault itself calling setOwners(...). The existing quorum must approve.
function setOwners(address[] memory newOwners, uint64 newThreshold) external onlyVault {
_setOwners(newOwners, newThreshold);
emit ConfigChanged(newOwners, newThreshold);
}
onlyVault checks that msg.sender == address(this), which can only be true when the call is the result of a successfully-executed transaction. This means owner-set changes inherit the same M-of-N security as any withdrawal.
Replay protection and confirmation revocation
Each transaction has its own auto-incrementing nonce. An owner can revoke their confirmation any time before execution, allowing for last-minute discoveries (wrong recipient, suspicious amount, etc.). After execution the confirmation is frozen.
function revokeConfirmation(uint256 txId) external onlyOwner txExists(txId) {
Transaction storage txn = _transactions[txId];
if (txn.executed) revert AlreadyExecuted();
if (!confirmed[txId][msg.sender]) revert NotConfirmed();
confirmed[txId][msg.sender] = false;
txn.confirmations -= 1;
emit ConfirmationRevoked(txId, msg.sender);
}
Test coverage
Nine tests: empty owner set rejection, threshold > owners rejection, duplicate owners rejection, non-owner submit rejection, full execution flow (fund → submit → confirm → execute), double-execution prevention, revocation before execution, revocation after execution (rejected), owner-set replacement via self-call.
Things this contract intentionally does not support
- Transaction expiration. A submitted transaction stays valid until executed or every confirmation is revoked. If you need expiration, fork and add it.
- Threshold change without owner change. The current API requires passing both
newOwnersandnewThresholdtosetOwners. To change only the threshold, pass the existing owner list. - ERC-721 / ERC-1155 custody. The contract handles ETH and ERC-20 only. NFT custody adds reentrancy considerations that are out of scope for the minimal vault.
For MPC (multi-party computation) key sharding — common in regulated institutional custody — combine this contract with a service like Fireblocks or Copper for the off-chain key management. We do this for several real-estate tokenization clients.
3. RoyaltyNFT.sol
ERC-721 with per-token metadata URI and EIP-2981 royalties. Suitable for fractional real-estate certificates, brand-collab drops, and any RWA scenario where each token represents a unique off-chain asset and a creator deserves secondary-market royalties.
new RoyaltyNFT(
"Acme Collection",
"ACME",
multisigOwner,
royaltyReceiver,
500 // 5% royalty in basis points
);
nft.mint(buyer, "ipfs://bafy.../1.json");
nft.setTokenRoyalty(1, premiumReceiver, 1000); // 10% for this special edition
EIP-2981 compliance
EIP-2981 is the industry standard for declaring royalties on-chain. Every major marketplace — OpenSea, Rarible, Magic Eden, Manifold, Blur (when enabled) — reads it via royaltyInfo(tokenId, salePrice). The contract returns (receiver, amount); the marketplace honors it.
function royaltyInfo(uint256 tokenId, uint256 salePrice)
public view override returns (address, uint256)
{
// Default royalty applies unless a per-token override exists
// OpenZeppelin's ERC2981 handles the lookup automatically
}
The OpenZeppelin ERC2981 mixin handles the math and the per-token override. We expose two owner-gated mutator functions:
function setDefaultRoyalty(address receiver, uint96 basisPoints) external onlyOwner {
_setDefaultRoyalty(receiver, basisPoints);
}
function setTokenRoyalty(uint256 tokenId, address receiver, uint96 basisPoints) external onlyOwner {
_setTokenRoyalty(tokenId, receiver, basisPoints);
}
setDefaultRoyalty updates the rate for all tokens minted after this call. Existing tokens keep whatever was in effect at their mint time unless explicitly overridden.
Sequential token IDs and metadata
Token IDs start at 1 (not 0 — keeps the contract compatible with marketplaces that treat tokenId == 0 as "not minted"). Each token has its own metadata URI, typically an ipfs://... link to a JSON descriptor following the OpenSea metadata standard.
function mint(address to, string calldata tokenURI_) external onlyOwner returns (uint256 tokenId) {
tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenURI_);
emit Minted(to, tokenId, tokenURI_);
}
In production deployments, RoyaltyNFT is typically wrapped in a marketplace contract that gates mint on payment, KYC verification, or whitelist membership. We've left this contract minimal so the wrapper can be the policy layer.
Test coverage
Six tests: name/symbol/EIP-2981 interface support, sequential mint, default royalty via royaltyInfo, default royalty update, per-token royalty override, non-owner mint/royalty rejection.
Stack and design choices
| Solidity | 0.8.24 |
| EVM target | Cancun (required for OpenZeppelin 5.1's mcopy opcode) |
| Optimizer | enabled, 200 runs, viaIR off |
| Framework | Hardhat 2.22 + hardhat-toolbox |
| Libraries | @openzeppelin/contracts 5.1 |
| Tests | TypeScript, chai, hardhat-network-helpers |
| Sample networks | Polygon, Base, Sepolia |
We use viaIR: false because for contracts this small the optimizer-with-viaIR path doesn't produce meaningfully better bytecode and it triples compile time. For a larger codebase the trade-off flips.
Custom errors throughout instead of require with string messages. Custom errors cost ~50% less gas on revert and are machine-readable in front-ends — a wallet can show AlreadyRevoked(0x1234...) and the dApp's React layer can react to the specific case.
error ScheduleAlreadyExists(address beneficiary);
error NoScheduleFound(address beneficiary);
error NothingToRelease(address beneficiary);
error AlreadyRevoked(address beneficiary);
error InvalidSchedule();
Compared to require(condition, "Schedule already exists for this address") — the custom error encodes the offending address in 4 bytes and produces a structured event the UI can handle.
What's coming in v0.2
- Deploy scripts per contract in
scripts/with sensible defaults for Polygon, Base, and Sepolia. - Gas reports in CI so we can spot regressions.
- Deployment addresses table in the README for our reference partners (with their consent).
WhitelistedERC20Vesting— a fork ofERC20Vestingwith KYC-gated grants for jurisdictions that require it.FractionalRealEstate— an RWA-specific helper combiningRoyaltyNFTwith shareholder voting via Snapshot.
If you have a use case that would benefit from a fourth template, open an issue describing it.
Install and try
git clone https://github.com/DualNova/tokenization-templates
cd tokenization-templates
npm install
npx hardhat compile
npx hardhat test
If you find a security issue, do not open a public issue — email [email protected].
- GitHub: github.com/DualNova/tokenization-templates
- Sister libraries in the same release: @dualnova/llms-txt and @dualnova/agent-skills
Built by DualNova — blockchain and AI software development for LATAM and the US. We've shipped ICO platforms, real-estate tokenization, and custodial vault infrastructure for clients in five jurisdictions. If you have a tokenization project that needs an audited team, book a 30-minute discovery call — free, no commitment, technical scoping only.