3 templates Solidity production-ready para tokenización regulada
Abrimos tres templates Solidity que usamos en plataformas de ICO y tokenización real-estate: ERC-20 con cliff y vesting lineal, vault custodial multi-firma M-de-N, y ERC-721 con royalties EIP-2981.
3 templates Solidity production-ready para tokenización regulada
TL;DR: Después de enviar plataformas de ICO, real-estate y minería de minerales tokenizados, seguíamos reescribiendo los mismos tres contratos: un ERC-20 con cliff + vesting lineal, un vault custodial multi-signature, y un ERC-721 con royalties EIP-2981. Los abrimos como DualNova/tokenization-templates — Solidity 0.8.24, OpenZeppelin 5.1, Hardhat 2.22, 25 tests unitarios, MIT. El repo no está auditado aún por un tercero; es un template para forkear, personalizar, y auditar antes de mainnet, no un drop-in production deploy.
Por qué estos tres contratos
Cada plataforma de tokenización que hemos enviado a producción ha necesitado las mismas tres primitives, sin importar el vertical:
- Vesting — para grants de equipo, allocations de advisors, y unlocks de seed-investors. Siempre con cliff. Siempre con la habilidad de revoke (los empleados se van; los advisors no entregan).
- Custodia — para la treasury que tiene los proceeds, la owner key del smart contract, y cualquier reserve operacional. Siempre multi-signature; en jurisdicciones reguladas, siempre documentado para los abogados.
- NFT con royalties — para certificados de real-estate fraccional, drops de brand-collaboration, y cualquier escenario RWA donde cada token representa un asset off-chain único y el issuer quiere un porcentaje de las ventas en mercado secundario.
Hemos escrito variantes de estos contratos en cinco engagements de clientes. Cada vez, las diferencias eran chicas (nombre del token distinto, lista de roles distinta, royalty rate distinto); cada vez, el trabajo de cleanup-and-test era desproporcionado.
Así que los limpiamos una vez para nosotros mismos, los endurecimos, escribimos una suite de tests enfocada, y los enviamos como templates MIT. Este post es la versión larga de qué hay en cada contrato y las decisiones de diseño que sobrevivieron producción.
Disclaimers honestos por adelantado
Estos contratos no han sido auditados por una firma tercero calificada. Han sido escritos cuidadosamente, revisados internamente, y tienen cobertura de tests unitarios tanto de happy como rejection paths — pero un test unitario no es una auditoría. El camino recomendado es:
- Forkea el repo.
- Personaliza los constructor parameters y cualquier comportamiento business-specific.
- Corre la suite de tests y añade tests para tus personalizaciones.
- Audita el resultado con una firma calificada de smart-contract security.
- Despliega a testnet, simula el lifecycle completo, y solo entonces push a mainnet.
DualNova tiene relaciones de trabajo con varias firmas de auditoría en LATAM y US; con gusto te conectamos. Email [email protected] antes de abrir cualquier issue público sobre una vulnerabilidad que encuentres.
Con eso dicho — a los contratos.
1. ERC20Vesting.sol
El pattern standard para distribuir tokens a un equipo, advisors y seed investors a lo largo del tiempo. Una schedule de vesting por beneficiary; nuevas schedules para la misma address revierten (usa una multisig separada por beneficiary si necesitas múltiples grants).
new ERC20Vesting("Acme Token", "ACME", 1_000_000e18, multisigOwner);
// Grant 100k tokens vesting sobre 4 años con cliff de 1 año
token.grant(
beneficiary,
100_000e18,
uint64(block.timestamp),
365 days, // cliff
4 * 365 days // duration
);
Semántica del cliff
El cliff es una pared dura. Nada es claimable antes de start + cliffSeconds. En el cliff, la porción completa del cliff se vuelve claimable atomically (entonces un cliff de 1 año sobre un vest de 4 años desbloquea exactamente 25% en el momento que el cliff transcurre). Después del cliff, el vesting es lineal hasta el final.
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);
}
El branch if (s.revoked) return s.total es la única línea no obvia. Está ahí porque revoke cappea el total de la schedule al amount vested-at-revoke, freezando la schedule — sin este branch, la fórmula lineal seguiría computando como si el vesting aún progresara.
Semántica del revoke
El owner puede revoke (e.g. para empleados terminados). La porción no vested en el momento de la revocation vuelve al owner; la porción ya vested se mantiene claimable por el beneficiary. Este es el pattern estándar de team-grant del 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);
}
Cosas que este contrato intencionalmente no soporta:
- Modificar una schedule existente. Para cambiar un grant, revoke y re-grant. Esto es deliberado — los edits silenciosos son footguns y pesadilla de auditoría.
- Múltiples schedules por address. Usa addresses separadas (e.g. una multisig por beneficiary).
- Revocation iniciada por el beneficiary. Solo el owner.
- Cualquiera puede llamar
release(beneficiary)para pushear los tokens vested al beneficiary. Los tokens siempre van al beneficiary sin importar quién llama — útil para keepers y claims con gas sponsorizado.
Cobertura de tests
Diez tests cubriendo: initial mint, params inválidos, schedule grant, double-grant rejection, cliff boundary, progresión lineal, release, revoke (con owner refund + beneficiary cleanup), double-revoke rejection, non-owner authorization. Todos pasan en Solidity 0.8.24 con viaIR off.
2. MultiSigVault.sol
Vault multi-signature M-de-N minimal para custodia de ETH y ERC-20. Diseñado para flows institucionales donde signers 2-de-3 o 3-de-5 aprueban withdrawals por transacción.
// Vault 2-de-3
new MultiSigVault([alice, bob, carol], 2);
// Cualquiera puede fundear (`receive() {}`)
// Un owner submitea un withdrawal:
uint256 txId = vault.submit(recipient, 1 ether, "");
// Un segundo owner confirma:
vault.confirm(txId);
// Cualquier owner ejecuta una vez se alcanza el threshold:
vault.execute(txId);
Por qué escribimos el nuestro en vez de usar Gnosis Safe
Gnosis Safe es excelente. También es una surface de dependencias enorme para clientes que solo necesitan un wallet 3-de-5 para una treasury de ICO. Los contratos de Safe son upgradeable proxies, el deployment toolchain es involucrado, y el audit trail es denso.
Para el 80% de nuestros clientes de tokenización, lo que realmente necesitan es: "una contract address, tres signer addresses, un threshold de dos, y una pestaña donde los signers pueden ver pending transactions." MultiSigVault son 150 líneas de Solidity, deploya en una sola transacción, y tiene cero capas de proxy. Para clientes que lo superan después, la migración a Gnosis Safe es una sola transacción aprobada por multisig.
Mutación del owner vía self-call
Agregar, remover o reemplazar owners se hace submiteando una transacción al vault mismo llamando a setOwners(...). El quorum existente debe aprobar.
function setOwners(address[] memory newOwners, uint64 newThreshold) external onlyVault {
_setOwners(newOwners, newThreshold);
emit ConfigChanged(newOwners, newThreshold);
}
onlyVault chequea que msg.sender == address(this), lo cual solo puede ser true cuando la call es el resultado de una transacción ejecutada exitosamente. Esto significa que los cambios al owner-set heredan la misma seguridad M-de-N que cualquier withdrawal.
Protección de replay y revocación de confirmación
Cada transacción tiene su propio nonce auto-incremental. Un owner puede revoke su confirmation cualquier momento antes de la ejecución, permitiendo descubrimientos de último minuto (recipient equivocado, amount sospechoso, etc.). Después de la ejecución la confirmation queda 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);
}
Cobertura de tests
Nueve tests: rejection de owner set vacío, rejection de threshold > owners, rejection de duplicate owners, rejection de submit por non-owner, full execution flow (fund → submit → confirm → execute), prevención de double-execution, revocation antes de ejecución, revocation después de ejecución (rechazada), reemplazo de owner-set vía self-call.
Cosas que este contrato intencionalmente no soporta
- Expiración de transacciones. Una transacción submiteada queda válida hasta ejecutarse o hasta que todas las confirmations se revoquen. Si necesitas expiración, forkea y agrégalo.
- Cambio de threshold sin cambio de owner. El API actual requiere pasar tanto
newOwnerscomonewThresholdasetOwners. Para cambiar solo el threshold, pasa la lista de owners existente. - Custodia de ERC-721 / ERC-1155. El contrato maneja solo ETH y ERC-20. La custodia de NFTs añade consideraciones de reentrancy que están fuera de scope para el vault minimal.
Para MPC (multi-party computation) key sharding — común en custodia institucional regulada — combina este contrato con un servicio como Fireblocks o Copper para el manejo off-chain de la key. Lo hacemos para varios clientes de tokenización real-estate.
3. RoyaltyNFT.sol
ERC-721 con metadata URI por token y royalties EIP-2981. Apropiado para certificados de real-estate fraccional, drops de brand-collab, y cualquier escenario RWA donde cada token representa un asset off-chain único y un creator merece royalties de mercado secundario.
new RoyaltyNFT(
"Acme Collection",
"ACME",
multisigOwner,
royaltyReceiver,
500 // 5% royalty en basis points
);
nft.mint(buyer, "ipfs://bafy.../1.json");
nft.setTokenRoyalty(1, premiumReceiver, 1000); // 10% para esta edición especial
Compliance EIP-2981
EIP-2981 es el standard de la industria para declarar royalties on-chain. Cada marketplace mayor — OpenSea, Rarible, Magic Eden, Manifold, Blur (cuando está enabled) — lo lee vía royaltyInfo(tokenId, salePrice). El contrato retorna (receiver, amount); el marketplace lo honra.
function royaltyInfo(uint256 tokenId, uint256 salePrice)
public view override returns (address, uint256)
{
// El default royalty aplica a menos que exista un override per-token
// El ERC2981 de OpenZeppelin maneja el lookup automáticamente
}
El mixin ERC2981 de OpenZeppelin maneja la matemática y el override per-token. Exponemos dos mutators owner-gated:
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 actualiza el rate para todos los tokens minteados después de esta call. Tokens existentes mantienen el rate vigente al mint time a menos que se sobrescriba explícitamente.
Token IDs secuenciales y metadata
Los token IDs empiezan en 1 (no en 0 — mantiene el contrato compatible con marketplaces que tratan tokenId == 0 como "no minteado"). Cada token tiene su propio metadata URI, típicamente un link ipfs://... a un descriptor JSON siguiendo el 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_);
}
En despliegues de producción, RoyaltyNFT típicamente se wrappea en un contrato de marketplace que gatea mint en pago, verificación KYC, o membresía de whitelist. Dejamos este contrato minimal para que el wrapper sea la capa de policy.
Cobertura de tests
Seis tests: soporte de name/symbol/EIP-2981 interface, sequential mint, default royalty vía royaltyInfo, default royalty update, override per-token royalty, rejection de mint/royalty por non-owner.
Stack y decisiones de diseño
| Solidity | 0.8.24 |
| EVM target | Cancun (requerido por el opcode mcopy de OpenZeppelin 5.1) |
| Optimizer | enabled, 200 runs, viaIR off |
| Framework | Hardhat 2.22 + hardhat-toolbox |
| Librerías | @openzeppelin/contracts 5.1 |
| Tests | TypeScript, chai, hardhat-network-helpers |
| Networks de ejemplo | Polygon, Base, Sepolia |
Usamos viaIR: false porque para contratos así de chicos el path optimizer-with-viaIR no produce bytecode significativamente mejor y triplica el compile time. Para un codebase más grande el trade-off cambia.
Custom errors throughout en vez de require con string messages. Los custom errors cuestan ~50% menos gas en revert y son machine-readable en front-ends — un wallet puede mostrar AlreadyRevoked(0x1234...) y la capa de React del dApp puede reaccionar al caso específico.
error ScheduleAlreadyExists(address beneficiary);
error NoScheduleFound(address beneficiary);
error NothingToRelease(address beneficiary);
error AlreadyRevoked(address beneficiary);
error InvalidSchedule();
Comparado a require(condition, "Schedule already exists for this address") — el custom error encodea la address offending en 4 bytes y produce un evento estructurado que el UI puede manejar.
Qué viene en v0.2
- Deploy scripts por contrato en
scripts/con defaults sensibles para Polygon, Base, y Sepolia. - Gas reports en CI para detectar regressions.
- Tabla de deployment addresses en el README para nuestros reference partners (con su consentimiento).
WhitelistedERC20Vesting— un fork deERC20Vestingcon grants KYC-gated para jurisdicciones que lo requieren.FractionalRealEstate— un helper RWA-specific combinandoRoyaltyNFTcon voto de shareholders vía Snapshot.
Si tienes un use case que se beneficiaría de un cuarto template, abre un issue describiéndolo.
Instalar y probar
git clone https://github.com/DualNova/tokenization-templates
cd tokenization-templates
npm install
npx hardhat compile
npx hardhat test
Si encuentras un security issue, no abras un issue público — email a [email protected].
- GitHub: github.com/DualNova/tokenization-templates
- Librerías hermanas del mismo release: @dualnova/llms-txt y @dualnova/agent-skills
Construido por DualNova — desarrollo de software blockchain y AI para LATAM y Estados Unidos. Hemos enviado plataformas de ICO, tokenización real-estate, e infraestructura de vault custodial para clientes en cinco jurisdicciones. Si tienes un proyecto de tokenización que necesita un equipo auditado, agenda una llamada de discovery de 30 minutos — gratis, sin compromiso, solo scoping técnico.