
Beltsys LabsDespliega la misma dirección de wallet en Ethereum, Polygon, BSC, Arbitrum y cualquier cadena EVM usando despliegues deterministas CREATE3. Guía completa con Solidity, Hardhat y TypeScript.
Imagina que estás construyendo una plataforma de pagos cripto. Un comerciante necesita recibir pagos en USDT, pero sus clientes usan diferentes cadenas: uno paga en Ethereum, otro en Polygon, otro en BSC. El enfoque ingenuo es generar una dirección diferente para cada cadena, lo que crea varios problemas:
La solución ideal es una sola dirección que funcione en todas las cadenas EVM. Cuando un usuario deposita fondos en 0xABC...123, ya sea en Ethereum, Polygon o Arbitrum, los fondos siempre llegan al mismo contrato controlado por tu plataforma.
Esto es exactamente lo que construiremos en este tutorial: una Wallet Factory determinista que genera la misma dirección de contrato en cualquier cadena EVM compatible, usando despliegues CREATE3 a través del contrato CreateX.
Para entender por qué CREATE3 es la herramienta correcta, primero necesitamos comparar los tres mecanismos de despliegue disponibles en la EVM:
| Característica | CREATE | CREATE2 | CREATE3 (vía CreateX) |
|---|---|---|---|
| Determinismo | No -- depende del nonce del deployer | Parcial -- depende del bytecode | Total -- solo depende del salt |
| Fórmula de dirección | keccak256(rlp(deployer, nonce)) |
keccak256(0xff, deployer, salt, keccak256(initCode)) |
keccak256(0xff, proxy, ...) donde proxy se crea con CREATE2 |
| Cambia si el bytecode cambia | N/A | Si -- cualquier cambio al constructor args modifica la dirección | No -- la dirección es independiente del bytecode |
| Cross-chain con misma dirección | Imposible sin sincronizar nonces | Requiere exactamente el mismo bytecode en todas las cadenas | Garantizado con el mismo salt y deployer |
| Caso de uso | Despliegues normales | Factories, counterfactual wallets | Infraestructura multichain |
CREATE2 parece determinista, pero la dirección depende del initCode (bytecode de creación + argumentos del constructor). Si tu contrato acepta una dirección de relayer como parámetro del constructor, y ese relayer es diferente en cada cadena, la dirección resultante sera diferente. CREATE3 resuelve esto al desacoplar completamente la dirección final del bytecode del contrato.
CreateX es un contrato factory desplegado en la misma dirección (0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed) en mas de 30 cadenas EVM. Fue creado por pcaversaccio (el autor de Snekmate y contribuidor de OpenZeppelin) y funciona como una infraestructura publica y sin permisos.
Su funcion deployCreate3 recibe un salt de 32 bytes y el initCode del contrato a desplegar. Internamente:
initCode con CREATE, desplegando tu contrato real.El sistema completo tiene tres componentes principales que interactuan de la siguiente manera:
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Backend │────────>│ WalletFactory │────────>│ WalletReceiver │
│ (Relayer) │ deploy │ (Ownable2Step) │ CREATE3 │ (Immutable) │
│ │ sweep │ │ via │ │
└─────────────┘ │ ┌────────────┐ │ CreateX │ ┌───────────┐ │
│ │ buildSalt │ │ │ │ sweep() │ │
│ │ compute │ │ │ │ sweepNat │ │
│ └────────────┘ │ │ │ receive │ │
└──────────────────┘ └─────────────────┘
│ │
│ computeWalletAddress │ Fondos
▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ CreateX │ │ Tesorería / │
│ 0xba5Ed...a5Ed │ │ Destinatarios │
└──────────────────┘ └─────────────────┘
Flujo de operación:
computeWalletAddress(walletId) para obtener la dirección determinista. Esto es una lectura gratuita (view function) que no requiere transacción.deployAndSweep() que atomicamente despliega el WalletReceiver y barre los fondos hacia la tesorería.sweepExisting() sin necesidad de redesplegar.mkdir wallet-factory-evm && cd wallet-factory-evm
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts dotenv
npx hardhat init
Selecciona "Create a TypeScript project" cuando Hardhat te lo pregunte.
Crea un archivo .env en la raíz del proyecto:
# Llaves privadas (NUNCA las subas a git)
PRIVATE_KEY=0x_tu_llave_privada_del_relayer
# RPCs
RPC_URL_SEPOLIA=https://sepolia.infura.io/v3/TU_API_KEY
RPC_URL_AMOY=https://rpc-amoy.polygon.technology
# Direcciones
RELAYER_ADDRESS=0x_tu_direccion_de_relayer
TREASURY_ADDRESS=0x_tu_direccion_de_tesoreria
# Verificación
ETHER_SCAN_API=tu_etherscan_api_key
Importante: agrega
.enva tu.gitignoreinmediatamente.
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "dotenv/config";
const config: HardhatUserConfig = {
solidity: {
version: "0.8.28",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
evmVersion: "paris",
},
},
networks: {
hardhat: {},
amoy: {
url: process.env.RPC_URL_AMOY,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 80002,
timeout: 120000,
gasPrice: 35000000000,
},
sepolia: {
url: process.env.RPC_URL_SEPOLIA,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 11155111,
}
},
etherscan: {
apiKey: process.env.ETHER_SCAN_API || "",
},
};
export default config;
Puntos clave de esta configuración:
El WalletReceiver es el contrato que se despliega en la dirección determinista. Su responsabilidad es simple: recibir fondos y permitir que el relayer autorizado los barra hacia uno o mas destinatarios.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/**
* @title WalletReceiver
* @author Beltsys Labs
* @notice Minimal contract that receives ERC20 or native tokens and allows
* the relayer to sweep funds to one or multiple destinations.
* @dev Deployed via CreateX (CREATE3), which guarantees the same address
* across all supported chains. All parameters are immutable to minimize
* gas usage and storage costs.
* @custom:version 1.0.1
* @custom:security-contact info@beltsys.com
*/
contract WalletReceiver is ReentrancyGuard {
using SafeERC20 for IERC20;
uint256 public constant MAX_RECIPIENTS = 5;
struct Recipient {
address wallet;
uint256 bps;
}
address public immutable relayer;
address public immutable factory;
bytes32 public immutable walletId;
event Swept(
bytes32 indexed walletId,
address indexed token,
uint256 totalAmount,
uint256 recipientCount
);
event NativeSwept(
bytes32 indexed walletId,
uint256 totalAmount,
uint256 recipientCount
);
error NotAuthorized();
error NothingToSweep();
error TransferFailed();
error ZeroAddress();
error InvalidRecipients();
error TooManyRecipients();
error BpsDoNotSum();
error BpsOverflow();
constructor(
address _relayer,
address _factory,
bytes32 _walletId
) {
if (_relayer == address(0)) revert ZeroAddress();
if (_factory == address(0)) revert ZeroAddress();
relayer = _relayer;
factory = _factory;
walletId = _walletId;
}
modifier onlyAuthorized() {
if (msg.sender != relayer && msg.sender != factory) revert NotAuthorized();
_;
}
function sweep(
address token,
Recipient[] calldata recipients
) external onlyAuthorized nonReentrant {
if (recipients.length == 0) revert InvalidRecipients();
if (recipients.length > MAX_RECIPIENTS) revert TooManyRecipients();
uint256 totalBps;
for (uint256 i = 0; i < recipients.length; i++) {
if (recipients[i].wallet == address(0)) revert ZeroAddress();
if (recipients[i].bps > 10_000) revert BpsOverflow();
totalBps += recipients[i].bps;
}
if (totalBps != 10_000) revert BpsDoNotSum();
uint256 balance = IERC20(token).balanceOf(address(this));
if (balance == 0) revert NothingToSweep();
uint256 distributed;
for (uint256 i = 0; i < recipients.length; i++) {
uint256 amount;
if (i == recipients.length - 1) {
amount = balance - distributed;
} else {
amount = (balance * recipients[i].bps) / 10_000;
}
if (amount > 0) {
IERC20(token).safeTransfer(recipients[i].wallet, amount);
distributed += amount;
}
}
emit Swept(walletId, token, balance, recipients.length);
}
function sweepNative(
Recipient[] calldata recipients
) external onlyAuthorized nonReentrant {
if (recipients.length == 0) revert InvalidRecipients();
if (recipients.length > MAX_RECIPIENTS) revert TooManyRecipients();
uint256 totalBps;
for (uint256 i = 0; i < recipients.length; i++) {
if (recipients[i].wallet == address(0)) revert ZeroAddress();
if (recipients[i].bps > 10_000) revert BpsOverflow();
totalBps += recipients[i].bps;
}
if (totalBps != 10_000) revert BpsDoNotSum();
uint256 bal = address(this).balance;
if (bal == 0) revert NothingToSweep();
uint256 distributed;
for (uint256 i = 0; i < recipients.length; i++) {
uint256 amount = i == recipients.length - 1
? bal - distributed
: (bal * recipients[i].bps) / 10_000;
if (amount > 0) {
distributed += amount;
(bool ok, ) = recipients[i].wallet.call{value: amount}("");
if (!ok) revert TransferFailed();
}
}
emit NativeSwept(walletId, bal, recipients.length);
}
receive() external payable {}
}
contract WalletReceiver is ReentrancyGuard {
using SafeERC20 for IERC20;
address public immutable relayer;
address public immutable factory;
bytes32 public immutable walletId;
El contrato hereda de ReentrancyGuard de OpenZeppelin para proteger las funciones de barrido contra ataques de reentrancia. Los tres parámetros principales se almacenan como immutable:
relayer: la EOA del backend autorizada para ejecutar barridos. Al ser immutable, su valor se almacena directamente en el bytecode del contrato, ahorrando ~2,100 gas en cada lectura (comparado con una variable de storage que cuesta un SLOAD).factory: la dirección del WalletFactory. Permite que la factory ejecute barridos directamente durante deployAndSweep().walletId: un identificador unico de 32 bytes para trazabilidad on-chain. Típicamente, es el ID de usuario de tu base de datos convertido a bytes32.El patrón using SafeERC20 for IERC20 envuelve todas las llamadas a funciones ERC20 con verificaciones adicionales. Esto es critico porque tokens como USDT no retornan bool en transfer(), lo que causaría un revert silencioso sin SafeERC20.
La función sweep() implementa un sistema de distribución basado en basis points (bps):
| Valor bps | Porcentaje | Uso tipico |
|---|---|---|
| 10,000 | 100% | Destinatario unico (tesorería) |
| 9,800 | 98% | Comerciante en un split 98/2 |
| 200 | 2% | Comisión de plataforma |
| 5,000 | 50% | Split 50/50 entre socios |
El truco del remainder: cuando divides un entero entre 10,000, puedes perder decimales por redondeo. El ultimo destinatario recibe balance - distributed en lugar de calcular su porcentaje, asegurando que no quede "polvo" (dust) atrapado en el contrato.
SafeERC20: la línea IERC20(token).safeTransfer(...) es critica. Tokens como USDT en Ethereum no cumplen exactamente con el estándar ERC20 -- su función transfer() no retorna un bool. Sin SafeERC20, la llamada podría fallar silenciosamente.
(bool ok, ) = recipients[i].wallet.call{value: amount}("");
if (!ok) revert TransferFailed();
Usamos call{value} en lugar de transfer() porque es el método recomendado desde el Istanbul hard fork. La protección contra reentrancia la provee el modifier nonReentrant de OpenZeppelin.
El WalletFactory es el contrato central que orquesta los despliegues deterministas y las operaciones de barrido.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "./WalletReceiver.sol";
interface ICreateX {
function deployCreate3(bytes32 salt, bytes memory initCode)
external payable returns (address);
function computeCreate3Address(bytes32 salt, address deployer)
external view returns (address);
}
/**
* @title WalletFactory
* @author Beltsys Labs
* @notice Factory contract that wraps CreateX to deploy deterministic
* WalletReceiver instances with the same address across all supported chains.
*/
contract WalletFactory is Ownable2Step, Pausable {
ICreateX public constant CREATEX =
ICreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed);
uint256 public constant MAX_RECIPIENTS = 5;
address public relayer;
mapping(address => bool) public isDeployedReceiver;
event WalletDeployed(bytes32 indexed walletId, address indexed receiver, uint256 indexed chainId);
event DeployedAndSwept(bytes32 indexed walletId, address indexed receiver, address indexed token, uint256 totalAmount, uint256 recipientCount);
event EmergencySweep(address indexed receiver, address indexed token, address indexed destination);
event RelayerUpdated(address indexed oldRelayer, address indexed newRelayer);
error NotRelayer();
error ZeroAddress();
error InvalidToken();
error InvalidReceiver();
error TooManyRecipients();
error InvalidSaltConstruction();
constructor(address _relayer) Ownable(msg.sender) {
if (_relayer == address(0)) revert ZeroAddress();
relayer = _relayer;
}
modifier onlyRelayer() {
if (msg.sender != relayer) revert NotRelayer();
_;
}
modifier onlyValidReceiver(address receiver) {
if (!isDeployedReceiver[receiver]) revert InvalidReceiver();
_;
}
modifier validRecipients(uint256 count) {
if (count > MAX_RECIPIENTS) revert TooManyRecipients();
_;
}
function setRelayer(address _relayer) external onlyOwner {
if (_relayer == address(0)) revert ZeroAddress();
emit RelayerUpdated(relayer, _relayer);
relayer = _relayer;
}
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
function buildSalt(bytes32 walletId) public view returns (bytes32 salt) {
salt = bytes32(
abi.encodePacked(
address(this),
hex"00",
bytes11(uint88(uint256(keccak256(abi.encodePacked(walletId)))))
)
);
assert(uint8(salt[20]) == 0x00);
}
function computeWalletAddress(bytes32 walletId)
external view returns (address walletAddress)
{
bytes32 salt = buildSalt(walletId);
bytes32 guardedSalt = keccak256(
abi.encodePacked(
bytes32(uint256(uint160(address(this)))),
salt
)
);
return CREATEX.computeCreate3Address(guardedSalt, address(CREATEX));
}
function deployWallet(bytes32 walletId)
external onlyRelayer whenNotPaused returns (address receiver)
{
bytes memory initCode = _buildInitCode(walletId);
receiver = CREATEX.deployCreate3(buildSalt(walletId), initCode);
isDeployedReceiver[receiver] = true;
emit WalletDeployed(walletId, receiver, block.chainid);
}
function deployAndSweep(
bytes32 walletId,
address token,
WalletReceiver.Recipient[] calldata recipients
)
external onlyRelayer whenNotPaused validRecipients(recipients.length)
returns (address receiver)
{
bytes memory initCode = _buildInitCode(walletId);
receiver = CREATEX.deployCreate3(buildSalt(walletId), initCode);
isDeployedReceiver[receiver] = true;
emit WalletDeployed(walletId, receiver, block.chainid);
if (token == address(0)) {
uint256 balance = receiver.balance;
if (balance > 0) {
WalletReceiver(payable(receiver)).sweepNative(recipients);
emit DeployedAndSwept(walletId, receiver, address(0), balance, recipients.length);
}
} else {
uint256 balance = IERC20(token).balanceOf(receiver);
if (balance > 0) {
WalletReceiver(payable(receiver)).sweep(token, recipients);
emit DeployedAndSwept(walletId, receiver, token, balance, recipients.length);
}
}
}
function sweepExisting(
address receiver, address token,
WalletReceiver.Recipient[] calldata recipients
) external onlyRelayer whenNotPaused onlyValidReceiver(receiver) validRecipients(recipients.length) {
if (token == address(0)) {
WalletReceiver(payable(receiver)).sweepNative(recipients);
} else {
WalletReceiver(payable(receiver)).sweep(token, recipients);
}
}
function emergencySweep(address receiver, address token, address destination)
external onlyOwner onlyValidReceiver(receiver)
{
if (destination == address(0)) revert ZeroAddress();
if (token == address(0)) revert InvalidToken();
WalletReceiver.Recipient[] memory recipients = new WalletReceiver.Recipient[](1);
recipients[0] = WalletReceiver.Recipient({ wallet: destination, bps: 10_000 });
WalletReceiver(payable(receiver)).sweep(token, recipients);
emit EmergencySweep(receiver, token, destination);
}
function emergencySweepNative(address receiver, address destination)
external onlyOwner onlyValidReceiver(receiver)
{
if (destination == address(0)) revert ZeroAddress();
WalletReceiver.Recipient[] memory recipients = new WalletReceiver.Recipient[](1);
recipients[0] = WalletReceiver.Recipient({ wallet: destination, bps: 10_000 });
WalletReceiver(payable(receiver)).sweepNative(recipients);
emit EmergencySweep(receiver, address(0), destination);
}
function _buildInitCode(bytes32 walletId) internal view returns (bytes memory initCode) {
return abi.encodePacked(
type(WalletReceiver).creationCode,
abi.encode(relayer, address(this), walletId)
);
}
}
La factory hereda de Ownable2Step (transferencia de propiedad en dos pasos) y Pausable (permite pausar operaciones en emergencia).
La dirección de CreateX (0xba5Ed...a5Ed) es constante porque es la misma en todas las cadenas EVM.
El mapping isDeployedReceiver registra todos los receivers desplegados, previniendo que un atacante pase una dirección maliciosa.
El salt es la pieza mas critica del sistema. CreateX espera un bytes32 con un formato específico:
┌──────────────────────┬────────┬─────────────────────────┐
│ 20 bytes │ 1 byte │ 11 bytes │
│ address(factory) │ 0x00 │ keccak256(walletId) │
│ Permissioned guard │ Flag │ Unique entropy │
└──────────────────────┴────────┴─────────────────────────┘
Byte 0-19 Byte 20 Byte 21-31
msg.sender coincida, previniendo front-running.0x00 = misma dirección en todas las cadenas. 0x01 = dirección diferente por cadena.walletId via keccak256.CreateX tiene una función interna _guard() que re-hashea el salt cuando los primeros 20 bytes coinciden con msg.sender. Esto significa que computeCreate3Address() (que es view y no aplica el guard) dará una dirección diferente si no replicas manualmente el re-hash:
bytes32 guardedSalt = keccak256(
abi.encodePacked(
bytes32(uint256(uint160(address(this)))),
salt
)
);
return CREATEX.computeCreate3Address(guardedSalt, address(CREATEX));
Lección clave: si tus direcciones precomputadas no coinciden con las desplegadas, el guard es la causa mas probable.
import { ethers } from 'hardhat'
import * as fs from 'fs'
const CREATEX_ADDRESS = ethers.getAddress('0xba5ed099633d3b313e4d5f7bdc1305d3c28ba5ed')
async function main() {
const [deployer] = await ethers.getSigners()
const network = await ethers.provider.getNetwork()
// Verificar CreateX
const code = await ethers.provider.getCode(CREATEX_ADDRESS)
if (code === '0x') throw new Error('CreateX no está deployado en esta red')
const RELAYER_ADDRESS = process.env.RELAYER_ADDRESS
if (!RELAYER_ADDRESS) throw new Error('Falta RELAYER_ADDRESS en .env')
const Factory = await ethers.getContractFactory('WalletFactory')
const factory = await Factory.deploy(RELAYER_ADDRESS)
await factory.waitForDeployment()
const factoryAddress = await factory.getAddress()
console.log(`WalletFactory deployado en: ${factoryAddress}`)
}
main().catch(err => { console.error(err); process.exit(1) })
import { ethers } from "ethers";
import * as fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();
const FACTORY_ABI = [
"function computeWalletAddress(bytes32 walletId) external view returns (address)"
];
async function main() {
const mongoId = process.argv[2] || "65d4f1a2b3c4d5e6f7a8b9c0";
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL_SEPOLIA);
const deployments = JSON.parse(fs.readFileSync("deployments.json", "utf8"));
const factory = new ethers.Contract(
deployments["11155111"].WalletFactory,
FACTORY_ABI,
provider
);
// MongoDB ObjectId (24 hex chars) → bytes32 (64 hex chars, zero-padded)
let walletId = "0x" + mongoId.padStart(64, "0");
const walletAddress = await factory.computeWalletAddress(walletId);
console.log(`Dirección de Wallet: ${walletAddress}`);
}
main().catch(console.error);
import { ethers } from "ethers";
import * as fs from "fs";
import * as dotenv from "dotenv";
dotenv.config();
const FACTORY_ABI = [
"function deployAndSweep(bytes32 walletId, address token, tuple(address wallet, uint256 bps)[] recipients) external returns (address)",
"function sweepExisting(address receiver, address token, tuple(address wallet, uint256 bps)[] recipients) external",
"function isDeployedReceiver(address) external view returns (bool)",
"function computeWalletAddress(bytes32 walletId) external view returns (address)"
];
async function main() {
const mongoId = process.argv[2] || "65d4f1a2b3c4d5e6f7a8b9c0";
const tokenAddress = process.argv[3] || "0x0000000000000000000000000000000000000000";
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL_SEPOLIA);
const relayer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const deployments = JSON.parse(fs.readFileSync("deployments.json", "utf8"));
const factory = new ethers.Contract(deployments["11155111"].WalletFactory, FACTORY_ABI, relayer);
let walletId = "0x" + mongoId.padStart(64, "0");
const walletAddress = await factory.computeWalletAddress(walletId);
const isDeployed = await factory.isDeployedReceiver(walletAddress);
const recipients = [{ wallet: process.env.TREASURY_ADDRESS, bps: 10000 }];
let tx;
if (isDeployed) {
tx = await factory.sweepExisting(walletAddress, tokenAddress, recipients);
} else {
tx = await factory.deployAndSweep(walletId, tokenAddress, recipients);
}
await tx.wait();
console.log("Retiro completado.");
}
main().catch(console.error);
┌─────────────────────────────────────────────────────────────┐
│ OWNER (Multisig) │
│ - setRelayer() - pause() / unpause() │
│ - emergencySweep() - emergencySweepNative() │
│ - transferOwnership() (Ownable2Step: requiere accept) │
└──────────────────────────┬──────────────────────────────────┘
│ onlyOwner
┌──────────────────────────┼──────────────────────────────────┐
│ RELAYER (Backend EOA) │
│ - deployWallet() - deployAndSweep() │
│ - sweepExisting() │
│ Todas requieren: whenNotPaused + onlyRelayer │
└──────────────────────────┬──────────────────────────────────┘
│ onlyAuthorized
┌──────────────────────────┼──────────────────────────────────┐
│ WALLET RECEIVER │
│ - sweep() - sweepNative() │
│ - receive() (payable, sin restricción) │
└─────────────────────────────────────────────────────────────┘
| Riesgo | Mitigación |
|---|---|
| Relayer comprometido |
pause() + setRelayer() para rotar |
| Owner comprometido |
Ownable2Step requiere aceptación explícita |
| Reentrancia en sweep |
ReentrancyGuard en WalletReceiver |
| Receiver malicioso |
isDeployedReceiver mapping valida origen |
| Dust por redondeo | Ultimo destinatario recibe remainder |
| Token no-estándar (USDT) |
SafeERC20 para todas las transferencias |
| Front-running | Salt permissioned (bytes 0-19 = factory address) |
En este tutorial implementamos un sistema completo de wallets deterministas multichain:
_guard().El código fuente completo está disponible en el repositorio wallet-factory-multichain en GitHub.
Desarrollado por Beltsys Labs. Licencia MIT.