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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ jobs:
working-directory: evm
steps:
- uses: actions/checkout@v4
with:
# forge-std is a pinned submodule (v1.16.2); openzeppelin and
# wraith-contracts are symlinks into evm/node_modules and evm/contracts.
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: 22
Expand All @@ -53,6 +57,47 @@ jobs:
- run: npx hardhat compile
- run: npx hardhat test

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: stable

- name: Run Foundry invariant tests
working-directory: evm/foundry
run: |
forge test --match-path 'test/invariant/*'

- name: Check gas snapshot (diff gate, +5% tolerance)
working-directory: evm/foundry
run: |
# Invariant suites carry run-to-run `reverts:` counters, so gate only
# the deterministic gas tests. Any regression >5% fails the PR.
forge snapshot --no-match-path 'test/invariant/*' --check --tolerance 5

- name: Run Slither
uses: crytic/slither-action@v0.4.0
continue-on-error: false
with:
target: evm
slither-config: evm/slither.config.json
fail-on: medium
solc-version: 0.8.28
sarif: evm/results.sarif

- name: Upload Slither SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: evm/results.sarif
category: slither

- name: Build subgraph
working-directory: evm/subgraph
run: |
npm ci
npm run codegen
npm run build

stellar:
needs: changes
if: needs.changes.outputs.stellar == 'true'
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ evm/cache/
evm/artifacts/
evm/typechain-types/
evm/dist/
evm/foundry/out/
evm/foundry/cache/
evm/subgraph/node_modules/
evm/subgraph/generated/
evm/subgraph/build/
evm/results.sarif

# Stellar
stellar/target/
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "evm/foundry/lib/forge-std"]
path = evm/foundry/lib/forge-std
url = https://github.com/foundry-rs/forge-std
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ cache
typechain-types
reference
stellar
**/foundry/out
**/foundry/cache
**/foundry/lib
**/subgraph/node_modules
**/subgraph/generated
**/subgraph/build
113 changes: 113 additions & 0 deletions evm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Wraith EVM Contracts

Solidity contracts for the Wraith stealth address platform. Built with Hardhat; tested with Hardhat + Chai, Foundry invariants, Slither, and a Goldsky/The Graph subgraph.

## Contracts

| Contract | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **ERC5564Announcer** | Minimal singleton emitting `Announcement(schemeId, stealthAddress, caller, ephemeralPubKey, metadata)` events per ERC-5564. No storage, no access control. |
| **ERC6538Registry** | Stealth meta-address registry per ERC-6538. Direct registration plus EIP-712 `registerKeysOnBehalf` with replay-protected nonces, and EIP-1271 smart-wallet signatures. |
| **WraithSender** | Atomic asset transfer + announcement in one transaction. `sendETH` / `sendERC20` (optional ETH gas tip) and `batchSendETH` / `batchSendERC20`. |
| **WraithNames** | Privacy-preserving `.wraith` name → meta-address registry. Ownership proven by secp256k1 spending-key signature over `keccak256(name, metaAddress)`. No wallet address stored. |
| **WraithWithdrawer** | EIP-7702 delegation target for gas-sponsored withdrawals. Sponsor fee variant (`withdrawETH` / `withdrawERC20`) and self-funded variants (`withdrawETHDirect` / `withdrawERC20Direct`). |

## Prerequisites

- Node.js 22+
- Foundry (forge) — <https://getfoundry.sh>
- Python 3 + `pip install slither-analyzer`
- `@graphprotocol/graph-cli` (installed via `npm ci` in `subgraph/`)

## Install

```bash
npm install
```

## Compile and test (Hardhat)

```bash
npx hardhat compile
npx hardhat test
```

## Invariant tests (Foundry)

Handler-based invariant suites under `foundry/test/invariant/`, covering
sender balance conservation, withdrawer atomicity, and name-registration
monotonicity. Each invariant runs 256 times.

`forge-std` is a pinned git submodule (v1.16.2), so initialize it before
building. The other two libraries under `foundry/lib/` are symlinks:
`openzeppelin` → `evm/node_modules/@openzeppelin` (so run `npm ci`
**first**, otherwise `forge` fails with a missing path) and
`wraith-contracts` → `evm/contracts`.

```bash
npm ci # needed for the openzeppelin symlink
cd foundry
git submodule update --init --recursive # fetch pinned forge-std
forge build
forge test --match-path 'test/invariant/*'
```

## Gas snapshots

`foundry/.gas-snapshot` is committed. Regenerate after intentional gas changes:

```bash
cd foundry
forge snapshot --no-match-path 'test/invariant/*'
```

The CI diff gate fails any PR that increases gas by more than 5%:

```bash
forge snapshot --no-match-path 'test/invariant/*' --check --tolerance 5
```

## Static analysis (Slither)

`slither.config.json` pins a curated detector set. CI requires zero High/Medium findings:

```bash
slither . --config-file slither.config.json --fail-medium
```

## Subgraph

`subgraph/` indexes all five contracts: event handlers for Announcer, Registry,
and Names, plus call handlers for Sender and Withdrawer. The manifest targets
Horizen Testnet; fill in the three `0x…dead` placeholder addresses (Names,
Sender, Withdrawer) from `scripts/deploy.ts` output before deploying.

```bash
cd subgraph
npm ci
npm run codegen # graph codegen
npm run build # graph build
```

Deploy to a local graph-node:

```bash
npm run create-local
npm run deploy-local
```

## Deployment

```bash
npx hardhat run scripts/deploy.ts --network <network>
```

The script deploys Announcer, Registry, Sender (pointing at the Announcer),
Names, and Withdrawer, logging each address. Point the subgraph at the deployed
addresses and redeploy.

## CI

`.github/workflows/ci.yml` runs: prettier, `hardhat compile`, `hardhat test`,
Foundry invariants, the gas-snapshot diff gate, Slither (zero High/Medium), and
`graph codegen && graph build`.
4 changes: 3 additions & 1 deletion evm/contracts/ERC6538Registry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ contract ERC6538Registry is IERC6538Registry {
bytes32 digest,
bytes memory signature
) private view {
// Try ecrecover first
// Try ecrecover first. The tuple is deliberately destructured: `err` is
// inspected below and `recovered` is compared against `signer`.
// slither-disable-next-line unused-return
(address recovered, ECDSA.RecoverError err, ) = ECDSA.tryRecover(digest, signature);

if (err == ECDSA.RecoverError.NoError && recovered == signer) {
Expand Down
2 changes: 1 addition & 1 deletion evm/contracts/WraithSender.sol
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ contract WraithSender is ReentrancyGuard {
amounts.length != len
) revert LengthMismatch();

uint256 totalSent;
uint256 totalSent = 0;
for (uint256 i; i < len; ) {
(bool sent, ) = stealthAddresses[i].call{value: amounts[i]}("");
require(sent);
Expand Down
12 changes: 12 additions & 0 deletions evm/contracts/WraithWithdrawer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ contract WraithWithdrawer is ReentrancyGuard {
uint256 sponsorFee
) external nonReentrant {
uint256 balance = address(this).balance;
// Exact zero-balance guard: a sponsor-fee withdrawal of a zero balance
// is a no-op, and the strict check distinguishes it from FeeTooHigh.
// slither-disable-next-line incorrect-equality
if (balance == 0) revert InsufficientBalance();
if (sponsorFee >= balance) revert FeeTooHigh();

Expand All @@ -54,6 +57,9 @@ contract WraithWithdrawer is ReentrancyGuard {
uint256 sponsorFee
) external nonReentrant {
uint256 balance = IERC20(token).balanceOf(address(this));
// Exact zero-balance guard: a sponsor-fee withdrawal of a zero balance
// is a no-op, and the strict check distinguishes it from FeeTooHigh.
// slither-disable-next-line incorrect-equality
if (balance == 0) revert InsufficientBalance();
if (sponsorFee >= balance) revert FeeTooHigh();

Expand All @@ -70,6 +76,9 @@ contract WraithWithdrawer is ReentrancyGuard {
/// @param destination The address to receive the withdrawal.
function withdrawETHDirect(address destination) external nonReentrant {
uint256 balance = address(this).balance;
// Exact zero-balance guard: withdrawing from an empty stealth address is
// a no-op, so the strict equality is intentional.
// slither-disable-next-line incorrect-equality
if (balance == 0) revert InsufficientBalance();

(bool sent, ) = destination.call{value: balance}("");
Expand All @@ -84,6 +93,9 @@ contract WraithWithdrawer is ReentrancyGuard {
address destination
) external nonReentrant {
uint256 balance = IERC20(token).balanceOf(address(this));
// Exact zero-balance guard: withdrawing from an empty stealth address is
// a no-op, so the strict equality is intentional.
// slither-disable-next-line incorrect-equality
if (balance == 0) revert InsufficientBalance();

IERC20(token).safeTransfer(destination, balance);
Expand Down
3 changes: 3 additions & 0 deletions evm/foundry/.gas-snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SendGas:testFuzz_batchSendErc20() (gas: 120884)
SendGas:testFuzz_sendErc20(uint96) (runs: 256, μ: 87030, ~: 87012)
SendGas:testFuzz_sendEth(uint96) (runs: 256, μ: 83101, ~: 83101)
27 changes: 27 additions & 0 deletions evm/foundry/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[profile.default]
src = "src"
test = "test"
out = "out"
libs = ["lib"]
solc_version = "0.8.28"
optimizer = true
optimizer_runs = 200
via_ir = true

# Invariant (fuzz) settings shared by all profiles.
# fail_on_revert=false is standard for handler-based suites: mutators may
# occasionally produce no-op/reverting calls (e.g. 0-amount sends) that must not
# corrupt the harness; invariants assert properties irrespective of those.
[invariant]
runs = 256
depth = 64
fail_on_revert = false
call_override = false

[fuzz]
runs = 256

[fmt]
line_length = 100
tab_width = 4
quote_style = "double"
1 change: 1 addition & 0 deletions evm/foundry/lib/forge-std
Submodule forge-std added at bf647b
1 change: 1 addition & 0 deletions evm/foundry/lib/openzeppelin
1 change: 1 addition & 0 deletions evm/foundry/lib/wraith-contracts
3 changes: 3 additions & 0 deletions evm/foundry/remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@openzeppelin/contracts/=lib/openzeppelin/contracts/
@wraith/contracts/=lib/wraith-contracts/
forge-std/=lib/forge-std/src/
65 changes: 65 additions & 0 deletions evm/foundry/test/SendGas.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {WraithSender} from "@wraith/contracts/WraithSender.sol";
import {ERC5564Announcer} from "@wraith/contracts/ERC5564Announcer.sol";
import {ERC20Mock} from "@wraith/contracts/test/ERC20Mock.sol";

/// @notice Fuzz tests that exercise the hot paths so `forge snapshot` records
/// representative gas costs for the CI diff gate.
contract SendGas is Test {
WraithSender internal sender;
ERC20Mock internal token;
address internal recipient;

function setUp() public {
ERC5564Announcer announcer = new ERC5564Announcer();
sender = new WraithSender(address(announcer));
token = new ERC20Mock();
recipient = makeAddr("stealth");
deal(address(this), 100 ether);
token.mint(address(this), 100_000 ether);
token.approve(address(sender), type(uint256).max);
}

function testFuzz_sendEth(uint96 amount) public {
vm.assume(amount > 0 && amount <= address(this).balance);
sender.sendETH{value: amount}(
1,
recipient,
abi.encodePacked(bytes32(uint256(1)), bytes1(0x02)),
hex"01"
);
}

function testFuzz_sendErc20(uint96 amount) public {
vm.assume(amount > 0 && amount <= token.balanceOf(address(this)));
sender.sendERC20(
address(token),
amount,
1,
recipient,
abi.encodePacked(bytes32(uint256(1)), bytes1(0x02)),
hex"01"
);
}

function testFuzz_batchSendErc20() public {
address[] memory tos = new address[](2);
bytes[] memory ephs = new bytes[](2);
bytes[] memory metas = new bytes[](2);
uint256[] memory amounts = new uint256[](2);
tos[0] = recipient;
tos[1] = makeAddr("stealth-2");
ephs[0] = hex"010101010101010101010101010101010101010101010101010101010101010101";
ephs[1] = ephs[0];
metas[0] = hex"01";
metas[1] = hex"01";
amounts[0] = 1 ether;
amounts[1] = 2 ether;
sender.batchSendERC20(
address(token), 1, tos, ephs, metas, amounts
);
}
}
Loading
Loading