Skip to content
Open
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
1 change: 1 addition & 0 deletions ethereum/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

# foundry and hardhat
cache/
dependencies/
out/
107 changes: 107 additions & 0 deletions ethereum/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# CoinList — Ethereum Contracts

Solidity contracts that power CoinList token sales. The core flow is a two-stage
pipeline: users' funds are gathered in a **Fund** (`commit`), and tokens are later
handed out by a **Distribution** (`distribute`). A separate **Swap** family provides
an alternate, integration-driven acquisition path.

Contracts are written in Solidity `0.8.34`, built and tested with
[Foundry](https://book.getfoundry.sh/), and depend on
[solady](https://github.com/Vectorized/solady) via [soldeer](https://soldeer.xyz/).

## Core concepts

### Sale id

Every deployed sale contract carries a `bytes32 id` — the unique identifier of the
sale it serves. The same `id` ties together a sale's Fund and Dist deployments.

### Kinds

Each deployable contract reports a `KIND` constant. The Registry keys deployments by
`(id, kind)`, so a single sale `id` can resolve to one contract of each kind.

| KIND | Contract | Role in the system |
|:----:|:----------------|:--------------------------------------------------------|
| `0` | Sale Fund | Collects funding (`commit`) and returns it (`remit`) |
| `1` | Sale Distribution | Pushes a token out to users (`distribute`) |
| `2` | Swap | Swaps an input token for an output token |

Contracts also expose a `VERSION` constant, bumped as features are added/refined.

### Registry key

```
registered[id][kind] => deployment address
```

A `(id, kind)` slot may be set once (registration reverts if non-zero) and cleared
via deregistration.

### Lifecycle primitives

Two distinct lifecycle mixins live in `lib/shared` and are applied to different
families:

- **`Stopable`** (Sale contracts) — a single `uint256 stopped` bitmask. `stop(level)`
sets it to any value (reversible). The `started(level)` modifier reverts with
`IsStopped` when `(stopped & level) != 0`. Owner-only on the Sale contracts.
- **`Operable`** (Swap contracts) — separate `paused` (a `uint32` bitmask, reversible
via `pause(level)`) and `stopped` (a one-way `stop()`, not reversible). `status()`
returns a `Status { State, uint32 flags }` where `State` is `Active | Paused |
Stopped`. The `active(level)` modifier reverts on `IsStopped` or when the level bit
is paused.


## Contracts

### Registry — `src/registry/Registry.sol`

On-chain directory mapping `(id, kind)` to a deployment address.

### Factory — `src/factory/Factory.sol`

Deploys Sale contracts, hands ownership to `deploymentOwner`, and registers them.

### TokenSaleFund — `src/sale/TokenSaleFund.sol` · kind `0`

Collects funding per `(user, option, token)`. Tracks `SaleTotal { commitCount, commitSum, remitCount, remitSum }`;

### TokenSaleDist — `src/sale/TokenSaleDist.sol` · kind `1`

Distributes a single, fixed `distToken`. Inherits `Stopable`, `OwnableRoles`.
Tracks `DistTotal { distCount, distSum }`.


### TokenSwap — `src/swap/TokenSwap.sol` · kind `2`

Abstract base for performing on chain token swaps. Tracks
`SwapTotal { inputSum, feeSum, outputSum, count }`.


## Shared library — `lib/shared`

Generic primitives used across `src/`.

- **`operable/Operable.sol`** — the Swap lifecycle mixin (`pause` / `stop` / `status`,
`active` modifier).
- **`stopable/Stopable.sol`** — the Sale lifecycle mixin (`stop(level)`, `started`
modifier).
- **`Utils.sol`** — `isContract(address)`, a free function using `extcodesize`.
- **`TestToken.sol`** — a mintable solady `ERC20` used only by the test suite.

## Build & test

Dependencies are managed with [soldeer](https://soldeer.xyz/) and pinned in
`soldeer.lock`; remappings are generated into `remappings.txt`.

```sh
forge soldeer install # populate dependencies/ from soldeer.lock

forge build
forge test
```

A `ci` profile (`FOUNDRY_PROFILE=ci`) raises verbosity for CI runs. RPC and Etherscan
config for `sepolia` / `anvil` is read from the environment (`SEPOLIA_RPC_URL`,
`ANVIL_RPC_URL`, `ETHERSCAN_API_KEY`).
26 changes: 26 additions & 0 deletions ethereum/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib", "dependencies"]

[profile.ci]
verbosity = 4

[soldeer]
remappings_generate = true
remappings_regenerate = true
remappings_version= true
remappings_location = "txt"

[dependencies]
solady = "0.1.26"
forge-std = "1.11.0"

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
anvil = "${ANVIL_RPC_URL}"

[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
28 changes: 28 additions & 0 deletions ethereum/lib/shared/TestToken.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.34;

import {ERC20} from "solady/tokens/ERC20.sol";

contract TestToken is ERC20 {
string private _name;
string private _symbol;

constructor(string memory n, string memory s) {
_name = n;
_symbol = s;
}

function name() public view override returns (string memory) {
return _name;
}

function symbol() public view override returns (string memory) {
return _symbol;
}

// so that we can easily create balances in tests
function mint(address to, uint256 amount) public returns (bool) {
_mint(to, amount);
return true;
}
}
12 changes: 12 additions & 0 deletions ethereum/lib/shared/Utils.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.34;

function isContract(address deployment) view returns (bool) {
uint32 size;

assembly {
size := extcodesize(deployment)
}

return size > 0;
}
33 changes: 33 additions & 0 deletions ethereum/lib/shared/operable/IOperable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.34;

import {Status} from "./Types.sol";

interface IOperable {
// ********************** Events *******************************************************

/// @notice Emitted when owner has set a new value for the paused level
event Paused(uint32 previous, uint32 next);

/// @notice Emitted when owner has permanently stopped this contract
event Stopped();

// ********************* API ***********************************************************

/// @notice return the current Status of this contract
function status() external view returns (Status memory);

/// @notice pause or unpause a chosen operation level
function pause(uint32 level) external returns (bool);

/// @notice stop any and all future operations in this contract
/// @dev reads will still be available
function stop() external returns (bool);

// *********************** Errors *********************************************************

/// @notice contract is paused at a given level
error IsPaused(uint32 level);

error IsStopped();
}
65 changes: 65 additions & 0 deletions ethereum/lib/shared/operable/Operable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.34;

import {IOperable} from "./IOperable.sol";
import {State, Status} from "./Types.sol";

abstract contract Operable is IOperable {
bool public stopped;

uint32 public paused;

// ********************* API *****************************************************

/// @dev returns a Status if present on this contract
function status() public virtual view returns (Status memory) {
Status memory stat;

// stopped takes precedence
if (stopped) {
stat.state = State.Stopped;
// we'll include a value in the flags here to indicate stopped came from us
stat.flags = 1;
} else if (paused > 0) {
stat.state = State.Paused;
stat.flags = paused;
}

return stat;
}

/// @dev override in child contract in order to set appropriate access control
function pause(uint32 level) public virtual returns (bool) {
uint32 prev = paused;

paused = level;

emit Paused(prev, paused);

return true;
}

/// @dev override in child contract in order to set appropriate access control
function stop() public virtual returns (bool) {
// NOTE: cannot be undone
stopped = true;

emit Stopped();

return stopped;
}

// ********************* Modifiers *****************************************************

modifier active(uint32 level) {
_active(level);
_;
}

function _active(uint32 level) internal view {
// stopped takes precedence regardless
require(!stopped, IsStopped());
// treat paused as a bitmask
require((paused & level) == 0, IsPaused(level));
}
}
13 changes: 13 additions & 0 deletions ethereum/lib/shared/operable/Types.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;

enum State {
Active, // active by default
Paused, // currently inactive, can be reactivated
Stopped // inactive, cannot be reactivated
}

struct Status {
State state; // one of the above
uint32 flags; // indication of internal status
}
22 changes: 22 additions & 0 deletions ethereum/lib/shared/stopable/IStopable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.34;

interface IStopable {
// ********************** Events *******************************************************

/// @notice Emitted when owner has set a new value for the stopped level
event Stopped(uint256 previous, uint256 next);

// ********************* API ***********************************************************

/// @notice stop method to be overriden in child contracts
/// @dev should take a level, set it as the current stop value. being available to owner only
/// @dev levels use bitmask values to work in conjunction with the ownableRoles library
/// see the abstract contract's `started` modifier for implementation
function stop(uint256 level) external returns (bool);

// *********************** Errors *********************************************************

/// @dev the sale feature has been stopped by an owner
error IsStopped();
}
33 changes: 33 additions & 0 deletions ethereum/lib/shared/stopable/Stopable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.34;

import {IStopable} from "./IStopable.sol";

abstract contract Stopable is IStopable {
/// @dev flag which represents the contract's current stop level
uint256 public stopped;

// ********************* API *****************************************************

function stop(uint256 level) public virtual returns (bool) {
uint256 prev = stopped;

stopped = level;

emit Stopped(prev, stopped);

return true;
}

// ********************* Modifiers *****************************************************

modifier started(uint256 level) {
_started(level);
_;
}

function _started(uint256 level) internal view {
// treat stopped as a bitmask
require((stopped & level) == 0, IsStopped());
}
}
11 changes: 11 additions & 0 deletions ethereum/remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
forge-std/=dependencies/forge-std-1.11.0/src/
solady/=dependencies/solady-0.1.26/src/
factory/=src/factory/
flying-tulip/=src/sale/flying-tulip/
sale/=src/sale/
superstate/=src/swap/superstate/
swap/=src/swap/
registry/=src/registry/
shared/=lib/shared/
forge-std-1.11.0/=dependencies/forge-std-1.11.0/src/
solady-0.1.26/=dependencies/solady-0.1.26/src/
13 changes: 13 additions & 0 deletions ethereum/soldeer.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[[dependencies]]
name = "forge-std"
version = "1.11.0"
url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_11_0_09-10-2025_06:23:22_forge-std-1.11.zip"
checksum = "0290ef84c693dc9086f98f6a9b4a69dc5c2b6aa1cfe10a989bd1def1a456c099"
integrity = "84aa7d32f8c7329468cf16f31f0f74e68072e634fdbde98f3bb00c6b136103b2"

[[dependencies]]
name = "solady"
version = "0.1.26"
url = "https://soldeer-revisions.s3.amazonaws.com/solady/0_1_26_25-08-2025_15:30:06_solady.zip"
checksum = "9872ac7cfd32c1eba32800508a1325c49f4a4aa8c6f670454db91971a583e26b"
integrity = "5da4b5ca9cbad98812a4b75ad528ff34c72a0b84433204be6d1420c81de1d6ff"
Loading