Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ DEMO_AAVE_POOL=
CHAIN=robinhood # robinhood (chain 46630) | arbitrumSepolia
GUARDIAN_IMPL= # required: GuardianModule impl on the target chain (printed by DeployGuardian)
RULES_ENGINE= # RulesEngineV1 (printed by DeployRules); also set VITE_RULES_ENGINE in site/.env
VAULT_FACTORY= # SafeVaultFactory (printed by DeployFactory); also set VITE_VAULT_FACTORY in site/.env

# ── Gasless onboarding relayer (site/api/onboard.ts; set on the host, e.g. Vercel) ──
RELAYER_PRIVATE_KEY= # a funded testnet key that sponsors the delegate+configure tx
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@ jobs:
cache-dependency-path: site/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck:api
- run: pnpm lint
26 changes: 14 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![Live demo](https://img.shields.io/badge/live-coincoin--five.vercel.app-000000?style=flat-square)](https://coincoin-five.vercel.app/)
[![Deployed](https://img.shields.io/badge/deployed-Robinhood%20Chain%20testnet%20(46630)-7af7c0?style=flat-square)](#deployed-addresses)
[![Solidity](https://img.shields.io/badge/Solidity-0.8.24-363636?style=flat-square&logo=solidity)](contracts/)
[![Tests](https://img.shields.io/badge/tests-90%20passing-27C93F?style=flat-square)](#testing)
[![CI](https://img.shields.io/github/actions/workflow/status/gamween/coincoin/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/gamween/coincoin/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-MIT-F5D90A?style=flat-square)](LICENSE)

</div>
Expand Down Expand Up @@ -49,7 +49,7 @@ coincoin closes the gap by enforcing at the account level via **EIP-7702** (a pr

Four parts. The product is the watcher daemon and the contracts; there is no required UI.

1. **Delegate (once).** An EOA signs an EIP-7702 authorization pointing at `GuardianModule`, and in the same transaction calls `configure()` to set its policy: a **frozen** safe vault (settable once — a leaked key cannot repoint it) and an authorized **keeper** set. Policies can also be installed from an **EIP-712 signature** a relayer submits (`configureWithSig`), for gasless onboarding.
1. **Delegate (once).** An EOA signs an EIP-7702 authorization pointing at `GuardianModule`, and in the same transaction calls `configure()` to set its policy: a **frozen** safe vault (settable once — a leaked key cannot repoint it) and an authorized **keeper** set. Policies can also be installed from an **EIP-712 signature** a relayer submits (`configureWithSig`) — this powers the **one-click, gasless onboarding** in the `/app` dashboard (the wallet just signs; a relayer pays).
2. **Watch.** A TypeScript/viem daemon (`ChainThreatSource`) polls `Drained` logs of the monitored protocols directly from chain — no third-party feed, no websocket dependency. It catches up to head in bounded `getLogs` windows.
3. **React.** On a verified threat, the **keeper** — which is cryptographically bounded to two actions and nothing else — calls `exitAaveV3()` to unwind deposited positions back into the account, then `evacuateERC20()` to sweep every token to the safe vault.
4. **Firewall (proactive).** Calls routed through `execute()` are scored by a stateless `RulesEngineV1`; an unlimited `approve` / `increaseAllowance` / EIP-2612 `permit` / blanket `setApprovalForAll` to an untrusted spender reverts at the account level before it lands.
Expand Down Expand Up @@ -93,13 +93,14 @@ Deployed and exercised on **Robinhood Chain testnet (chain 46630)** unless noted
|---|---|
| ✅ | EIP-7702 delegation + self-config · ERC-20 sweep · approval revocation |
| ✅ | Frozen vault · signed multi-keeper policy (EIP-712 `configureWithSig`) |
| ✅ | **Gasless in-browser onboarding** — the wallet signs (EIP-712 policy + EIP-7702 authorization); a relayer (`site/api`) deploys the user's deterministic `SafeVaultFactory` vault and sponsors the delegate+configure tx (zero gas for the user) |
| ✅ | Local firewall (`RulesEngineV1`) — unlimited-approval / `permit` / `setApprovalForAll` rules |
| ✅ | Real on-chain detection → rescue loop, run end-to-end (funds at rest **and** a deposited DeFi position rescued in one keeper-driven sequence) |
| ✅ | DeFi-exit engine (`exitAaveV3`) — built and verified against the **live Aave V3 Pool** via an Arbitrum One fork test |
| 🛣️ | **Native Aave V3 integration** — built and ready; goes live the day Aave V3 is deployed on Robinhood Chain. Waiting on availability, not on code. |
| 🛣️ | **GMX V2 position exit** — same pattern, once GMX is available on the target chain |
| 🛣️ | Broader firewall coverage — Permit2, multicall, direct-transfer heuristics |
| 🛣️ | Policy asset/protocol scoping · Stylus rules engine · in-browser 7702 onboarding · security audit |
| 🛣️ | Policy asset/protocol scoping · Stylus rules engine · security audit |

> [!NOTE]
> Aave V3 and GMX V2 are not deployed on Robinhood Chain testnet, so the live end-to-end run exercises the exit engine against an Aave-V3-shaped lending pool. The exit code itself is verified against the real Aave V3 Pool in the fork test (`AaveRealFork.t.sol`) — it ships against the real protocol the moment one is available on the chain coincoin runs on.
Expand All @@ -117,11 +118,11 @@ Deployed and exercised on **Robinhood Chain testnet (chain 46630)** unless noted

```
coincoin/
├── contracts/ Foundry — GuardianModule (EIP-7702), RulesEngineV1, SafeVault, mocks, deploy scripts
├── contracts/ Foundry — GuardianModule (EIP-7702), RulesEngineV1, SafeVault(+Factory), mocks, scripts
├── watcher/ TypeScript/viem detection → rescue daemon (onboard · watch · exploit · revoke)
├── site/ Vite/React/Tailwind landing + /app dashboard
├── site/ Vite/React/Tailwind landing + /app dashboard + api/ (gasless onboarding relayer)
├── deployments/ On-chain address records (robinhood-testnet.json, arbitrum-sepolia.json)
├── docs/ Brand kit, design specs, submission notes
├── docs/readme/ README images
├── video/ Remotion pitch + demo videos (code → MP4)
├── .env.example Config template (RPC, disposable keys, demo addresses)
└── LICENSE MIT
Expand Down Expand Up @@ -160,9 +161,10 @@ Robinhood Chain testnet (chain `46630`) — [explorer](https://explorer.testnet.

| Contract | Address |
|---|---|
| `GuardianModule` (EIP-7702 guardian) | [`0xd0d301Aeaa7AA5Ced16C927030f131c9Cb083b77`](https://explorer.testnet.chain.robinhood.com/address/0xd0d301Aeaa7AA5Ced16C927030f131c9Cb083b77) |
| `GuardianModule` (EIP-7702 guardian) | [`0x9953BB30cFef2ac842C74417eA6DC661b492E8dA`](https://explorer.testnet.chain.robinhood.com/address/0x9953BB30cFef2ac842C74417eA6DC661b492E8dA) |
| `RulesEngineV1` (firewall) | [`0xc20A9d7D38B07a9C74A1fD87A2e25CA1973Cbc52`](https://explorer.testnet.chain.robinhood.com/address/0xc20A9d7D38B07a9C74A1fD87A2e25CA1973Cbc52) |
| `SafeVault` (demo) | [`0x49be3DC48fC0540346A064fCC6Fc94FBaE62f479`](https://explorer.testnet.chain.robinhood.com/address/0x49be3DC48fC0540346A064fCC6Fc94FBaE62f479) |
| `SafeVaultFactory` (deterministic per-user vault) | [`0x1ef2B2539fa842A9c7e4EA07790aA6dBc47ec4A5`](https://explorer.testnet.chain.robinhood.com/address/0x1ef2B2539fa842A9c7e4EA07790aA6dBc47ec4A5) |
| `SafeVault` (demo) | [`0x530921CFFCeCc01B3Ad20E48A8c1707d27204b91`](https://explorer.testnet.chain.robinhood.com/address/0x530921CFFCeCc01B3Ad20E48A8c1707d27204b91) |

The `GuardianModule` was also initially deployed and **Arbiscan-verified** on Arbitrum Sepolia at [`0x6671…200F`](https://sepolia.arbiscan.io/address/0x6671b4B73b79c284A710B00ef777d8E65f55200F).

Expand All @@ -187,15 +189,15 @@ Non-custodial by construction, but **experimental and unaudited** — a research

## Testing

90 tests, written test-first.
95 tests, written test-first.

```bash
cd contracts && forge test # 64 unit/integration tests (+1 fork test, gated on ARBITRUM_ONE_RPC)
cd contracts && forge test # 69 unit/integration tests (+1 fork test, gated on ARBITRUM_ONE_RPC)
ARBITRUM_ONE_RPC=<rpc> forge test # includes AaveRealFork.t.sol against the live Aave V3 Pool
cd ../watcher && pnpm test # 25 watcher tests (vitest)
```

`AaveRealFork.t.sol` exits a real position against the **live Aave V3 Pool on Arbitrum One** (forked) — the same code path the guardian runs. The watcher suite covers the alert schema, exposure registry, keeper client, and the end-to-end orchestrator.
`AaveRealFork.t.sol` exits a real position against the **live Aave V3 Pool on Arbitrum One** (forked) — the same code path the guardian runs. The watcher suite covers the alert schema, exposure registry, keeper client, and the end-to-end orchestrator. The gasless onboarding path is proven end-to-end on testnet by `pnpm onboard:gasless` (a fresh, unfunded EOA signs; a relayer sponsors the delegate+configure tx).

## Buildathon

Expand All @@ -206,5 +208,5 @@ Built for **[Arbitrum Open House London](https://arbitrum-london.hackquest.io/)*
[MIT](LICENSE) © 2026 coincoin

<div align="center">
<sub>Built for ETHGlobal New York 2026 · Hedera — Tokenization track</sub>
<sub>Built for Arbitrum Open House London 2026 · Robinhood Chain track</sub>
</div>
18 changes: 18 additions & 0 deletions contracts/script/DeployFactory.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script, console2} from "forge-std/Script.sol";
import {SafeVaultFactory} from "../src/SafeVaultFactory.sol";

/// @notice Deploys the SafeVaultFactory (deterministic per-owner SafeVault deployer).
/// Set its address as `VAULT_FACTORY` (.env) and `VITE_VAULT_FACTORY` (site)
/// to enable the gasless in-browser onboarding flow.
contract DeployFactory is Script {
function run() external returns (SafeVaultFactory factory) {
uint256 pk = vm.envUint("DEPLOYER_PRIVATE_KEY");
vm.startBroadcast(pk);
factory = new SafeVaultFactory();
vm.stopBroadcast();
console2.log("SafeVaultFactory deployed at:", address(factory));
}
}
13 changes: 12 additions & 1 deletion contracts/src/GuardianModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ contract GuardianModule {
if (block.timestamp > deadline) revert Expired();
if (nonce != policyNonce) revert BadNonce();
bytes32 structHash = keccak256(
abi.encode(POLICY_TYPEHASH, p.safeVault, keccak256(abi.encodePacked(p.keepers)), nonce, deadline)
abi.encode(POLICY_TYPEHASH, p.safeVault, _hashKeepers(p.keepers), nonce, deadline)
);
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash));
if (ECDSA.recover(digest, sig) != address(this)) revert BadSignature();
Expand All @@ -99,6 +99,17 @@ contract GuardianModule {
_applyPolicy(p.safeVault, p.keepers);
}

/// @dev EIP-712 hash of the `address[] keepers` field: keccak256 of each address encoded as a
/// 32-byte word, concatenated — the standard array encoding, so a wallet's `signTypedData`
/// (e.g. in-browser gasless onboarding) recovers correctly.
function _hashKeepers(address[] calldata keepers_) internal pure returns (bytes32) {
bytes32[] memory words = new bytes32[](keepers_.length);
for (uint256 i; i < keepers_.length; ++i) {
words[i] = bytes32(uint256(uint160(keepers_[i])));
}
return keccak256(abi.encodePacked(words));
}

/// @notice The full authorized keeper set.
function keepers() external view returns (address[] memory) {
return _keeperList;
Expand Down
46 changes: 46 additions & 0 deletions contracts/src/SafeVaultFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Create2} from "@openzeppelin/contracts/utils/Create2.sol";
import {SafeVault} from "./SafeVault.sol";

/// @title SafeVaultFactory
/// @notice Deploys one SafeVault per owner at a deterministic CREATE2 address. The
/// address is therefore known (counterfactual) BEFORE deployment, which is
/// what the gasless onboarding flow needs: the user signs an EIP-712 policy
/// against `vaultOf(user)`, then a relayer deploys the vault and configures
/// the guardian in one sponsored transaction.
contract SafeVaultFactory {
event VaultDeployed(address indexed owner, address vault);

/// @dev One vault per owner → the salt is the owner address.
function _salt(address owner) internal pure returns (bytes32) {
return bytes32(uint256(uint160(owner)));
}

/// @dev Init code = SafeVault creation bytecode + abi-encoded constructor arg (owner).
function _initCode(address owner) internal pure returns (bytes memory) {
return abi.encodePacked(type(SafeVault).creationCode, abi.encode(owner));
}

/// @notice The deterministic SafeVault address for `owner`, deployed or not.
function vaultOf(address owner) public view returns (address) {
return Create2.computeAddress(_salt(owner), keccak256(_initCode(owner)));
}

/// @notice Whether `owner`'s SafeVault has already been deployed.
function isDeployed(address owner) external view returns (bool) {
return vaultOf(owner).code.length != 0;
}

/// @notice Deploy `owner`'s SafeVault if it doesn't exist yet; returns its address.
/// Idempotent: a second call just returns the existing vault.
function deploy(address owner) external returns (address vault) {
vault = vaultOf(owner);
if (vault.code.length == 0) {
address deployed = Create2.deploy(0, _salt(owner), _initCode(owner));
require(deployed == vault, "factory: address mismatch");
emit VaultDeployed(owner, vault);
}
}
}
5 changes: 4 additions & 1 deletion contracts/test/PreAuthRegistry.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,10 @@ contract PreAuthSigTest is Test {
bytes32 domainSep = keccak256(
abi.encode(DOMAIN_TYPEHASH, keccak256(bytes("coincoin GuardianModule")), keccak256(bytes("1")), block.chainid, user)
);
bytes32 structHash = keccak256(abi.encode(POLICY_TYPEHASH, v, keccak256(abi.encodePacked(ks)), nonce, deadline));
bytes32[] memory words = new bytes32[](ks.length);
for (uint256 i; i < ks.length; ++i) words[i] = bytes32(uint256(uint160(ks[i])));
bytes32 keepersHash = keccak256(abi.encodePacked(words)); // standard EIP-712 address[] hash
bytes32 structHash = keccak256(abi.encode(POLICY_TYPEHASH, v, keepersHash, nonce, deadline));
return keccak256(abi.encodePacked("\x19\x01", domainSep, structHash));
}

Expand Down
45 changes: 45 additions & 0 deletions contracts/test/SafeVaultFactory.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {SafeVaultFactory} from "../src/SafeVaultFactory.sol";
import {SafeVault} from "../src/SafeVault.sol";

contract SafeVaultFactoryTest is Test {
SafeVaultFactory factory;
address alice = address(0xA11CE);
address bob = address(0xB0B);

function setUp() public {
factory = new SafeVaultFactory();
}

function test_VaultOfMatchesDeployedAddress() public {
address predicted = factory.vaultOf(alice);
assertEq(predicted.code.length, 0, "should not exist yet");
address deployed = factory.deploy(alice);
assertEq(deployed, predicted, "deployed address must equal vaultOf");
assertGt(deployed.code.length, 0, "vault should have code");
}

function test_DeployedVaultIsOwnedByOwner() public {
address vault = factory.deploy(alice);
assertEq(SafeVault(payable(vault)).owner(), alice, "vault owner must be the user");
}

function test_DeployIsIdempotent() public {
address first = factory.deploy(alice);
address second = factory.deploy(alice); // must not revert
assertEq(first, second, "second deploy returns the same vault");
}

function test_IsDeployedReflectsState() public {
assertFalse(factory.isDeployed(alice));
factory.deploy(alice);
assertTrue(factory.isDeployed(alice));
}

function test_DistinctOwnersGetDistinctVaults() public {
assertTrue(factory.vaultOf(alice) != factory.vaultOf(bob), "vaults must differ per owner");
}
}
22 changes: 12 additions & 10 deletions deployments/robinhood-testnet.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
"network": "robinhood-chain-testnet",
"rpc": "https://rpc.testnet.chain.robinhood.com/rpc",
"contracts": {
"GuardianModule": "0xd0d301Aeaa7AA5Ced16C927030f131c9Cb083b77",
"RulesEngineV1": "0xc20A9d7D38B07a9C74A1fD87A2e25CA1973Cbc52"
"GuardianModule": "0x9953BB30cFef2ac842C74417eA6DC661b492E8dA",
"RulesEngineV1": "0xc20A9d7D38B07a9C74A1fD87A2e25CA1973Cbc52",
"SafeVaultFactory": "0x1ef2B2539fa842A9c7e4EA07790aA6dBc47ec4A5"
},
"demo": {
"_comment": "Live end-to-end detection->evacuation + DeFi-exit scenario (pnpm onboard/watch/exploit). MockAavePool holds a deposited position credited to the victim (exitAaveV3 target).",
"SafeVault": "0x49be3DC48fC0540346A064fCC6Fc94FBaE62f479",
"MockERC20": "0xC32C2eB815F1413ee2c7A68d2EFf3760d828841E",
"MockVulnerableProtocol": "0x6e8086CF791754b93bAf04F039b9f72e2bCF80Db",
"MockAavePool": "0xaf57676673B71CED42767841F1317A20484052BE",
"victim": "0xfa142e801447fc359E54e8E797357aA7cBf23368"
"SafeVault": "0x530921CFFCeCc01B3Ad20E48A8c1707d27204b91",
"MockERC20": "0x31052145BBFB8aA9B4e3713a2fD34e57b3A942f3",
"MockVulnerableProtocol": "0x43FfEEA2eAea3e2FB504cFF746b77171f86f6Ec0",
"MockAavePool": "0x7E8c3a15E45418c4B187505Bb21FBB05015aca93",
"victim": "0x46d167056f22BFB13bc2F940ae42b29c514280Cc"
},
"deployedAt": "2026-06-14",
"deployedAt": "2026-06-16",
"deployer": "0xdae8992a9b5Fe850bE63781d1c2e65a3e496F728",
"explorer": "https://explorer.testnet.chain.robinhood.com/address/0xd0d301Aeaa7AA5Ced16C927030f131c9Cb083b77",
"note": "Robinhood Chain testnet (Arbitrum Orbit). This GuardianModule is the CURRENT build: EIP-7702 delegation + self-config, ERC-20 sweep, approval revocation, on-chain detection->evacuation, frozen vault, signed multi-keeper policy (configureWithSig), exitAaveV3 (DeFi exit), and the local firewall (execute + RulesEngineV1). The DeFi exit is also fork-verified against real Aave V3 on Arbitrum One (Aave isn't deployed on this chain; MockAavePool stands in here). Supersedes the earlier build 0x6671b4B73b79c284A710B00ef777d8E65f55200F (delegation + sweep + revoke only)."
"keeper": "0x627872F35b724222413e7421C9e40A26B2762B9e",
"explorer": "https://explorer.testnet.chain.robinhood.com/address/0x9953BB30cFef2ac842C74417eA6DC661b492E8dA",
"note": "Robinhood Chain testnet (Arbitrum Orbit). Current GuardianModule build: EIP-7702 delegation + self-config, ERC-20 sweep, approval revocation, on-chain detection->evacuation, frozen vault, signed multi-keeper policy (configureWithSig, EIP-712 standard encoding so a browser wallet's signTypedData verifies), exitAaveV3 (DeFi exit), and the local firewall (execute + RulesEngineV1). SafeVaultFactory deploys a deterministic per-user SafeVault for gasless in-browser onboarding (relayer-sponsored). Supersedes 0xd0d301Aeaa7AA5Ced16C927030f131c9Cb083b77 (pre-EIP-712-fix)."
}
4 changes: 4 additions & 0 deletions site/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
# RulesEngineV1 address on Robinhood Chain — enables the dashboard's firewall controls.
# Deploy it with contracts/script/DeployRules.s.sol; empty = the Enable button stays off.
VITE_RULES_ENGINE=

# SafeVaultFactory address (gasless onboarding). Optional — falls back to the deployed constant
# in src/app/contracts.ts. Deploy with contracts/script/DeployFactory.s.sol.
VITE_VAULT_FACTORY=
Loading
Loading