diff --git a/ethereum/.gitignore b/ethereum/.gitignore index 00dd46a..cafd267 100644 --- a/ethereum/.gitignore +++ b/ethereum/.gitignore @@ -1,4 +1,5 @@ # foundry and hardhat cache/ +dependencies/ out/ diff --git a/ethereum/README.md b/ethereum/README.md new file mode 100644 index 0000000..0bc27e7 --- /dev/null +++ b/ethereum/README.md @@ -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`). diff --git a/ethereum/foundry.toml b/ethereum/foundry.toml new file mode 100644 index 0000000..96fba3b --- /dev/null +++ b/ethereum/foundry.toml @@ -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 diff --git a/ethereum/lib/shared/TestToken.sol b/ethereum/lib/shared/TestToken.sol new file mode 100644 index 0000000..5ea03d6 --- /dev/null +++ b/ethereum/lib/shared/TestToken.sol @@ -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; + } +} diff --git a/ethereum/lib/shared/Utils.sol b/ethereum/lib/shared/Utils.sol new file mode 100644 index 0000000..1d0da1a --- /dev/null +++ b/ethereum/lib/shared/Utils.sol @@ -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; +} diff --git a/ethereum/lib/shared/operable/IOperable.sol b/ethereum/lib/shared/operable/IOperable.sol new file mode 100644 index 0000000..426461a --- /dev/null +++ b/ethereum/lib/shared/operable/IOperable.sol @@ -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(); +} diff --git a/ethereum/lib/shared/operable/Operable.sol b/ethereum/lib/shared/operable/Operable.sol new file mode 100644 index 0000000..0246ebd --- /dev/null +++ b/ethereum/lib/shared/operable/Operable.sol @@ -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)); + } +} diff --git a/ethereum/lib/shared/operable/Types.sol b/ethereum/lib/shared/operable/Types.sol new file mode 100644 index 0000000..a3c13f3 --- /dev/null +++ b/ethereum/lib/shared/operable/Types.sol @@ -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 +} diff --git a/ethereum/lib/shared/stopable/IStopable.sol b/ethereum/lib/shared/stopable/IStopable.sol new file mode 100644 index 0000000..4a3e161 --- /dev/null +++ b/ethereum/lib/shared/stopable/IStopable.sol @@ -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(); +} diff --git a/ethereum/lib/shared/stopable/Stopable.sol b/ethereum/lib/shared/stopable/Stopable.sol new file mode 100644 index 0000000..3fb46f2 --- /dev/null +++ b/ethereum/lib/shared/stopable/Stopable.sol @@ -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()); + } +} diff --git a/ethereum/remappings.txt b/ethereum/remappings.txt new file mode 100644 index 0000000..738a1bc --- /dev/null +++ b/ethereum/remappings.txt @@ -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/ diff --git a/ethereum/soldeer.lock b/ethereum/soldeer.lock new file mode 100644 index 0000000..842e7ad --- /dev/null +++ b/ethereum/soldeer.lock @@ -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" diff --git a/ethereum/src/factory/Factory.sol b/ethereum/src/factory/Factory.sol new file mode 100644 index 0000000..feaaee7 --- /dev/null +++ b/ethereum/src/factory/Factory.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OwnableRoles} from "solady/auth/OwnableRoles.sol"; +import {Registry} from "registry/Registry.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; +import {TokenSaleDist} from "sale/TokenSaleDist.sol"; +import {isContract} from "shared/Utils.sol"; +import {IFactory} from "./IFactory.sol"; + +contract Factory is IFactory, OwnableRoles { + // ************************ State *************************************************************** + + /// @dev non owner role that can deploy + uint256 public constant DEPLOY_LEVEL = 2; + /// @dev address of the deployed registry contract + address public registry; + /// @dev address set to owner of any deployed contracts + address public deploymentOwner; + + constructor(address reg, address depOwner) { + // invariant: non zero address is a contract + require(reg != address(0) && isContract(reg), InvalidAddress()); + // invariant: deploymentOwner must be set to non zero + require(depOwner != address(0), InvalidAddress()); + + _initializeOwner(msg.sender); + registry = reg; + deploymentOwner = depOwner; + } + + // ************************ Admin API *********************************************************** + + function deployFund(uint256 min, bytes32 id) external onlyRoles(DEPLOY_LEVEL) returns (bool) { + (TokenSaleFund fund, uint256 kind, address addr) = _deployFund(min, id); + + fund.transferOwnership(deploymentOwner); + + // invariant: registration succeeds + require(Registry(registry).register(id, kind, addr)); + + return true; + } + + function deployFund(uint256 min, bytes32 id, address admin) external onlyRoles(DEPLOY_LEVEL) returns (bool) { + require(admin != address(0), InvalidAddress()); + + (TokenSaleFund fund, uint256 kind, address addr) = _deployFund(min, id); + + fund.grantRoles(admin, fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + fund.transferOwnership(deploymentOwner); + + // invariant: registration succeeds + require(Registry(registry).register(id, kind, addr)); + + return true; + } + + function _deployFund(uint256 min, bytes32 id) internal returns (TokenSaleFund, uint256, address) { + TokenSaleFund fund = new TokenSaleFund(min, id); + uint256 kind = fund.KIND(); + address addr = address(fund); + + emit Deployed(id, kind, addr); + + return (fund, kind, addr); + } + + function deployDist(address dToken, bytes32 id) external onlyRoles(DEPLOY_LEVEL) returns (bool) { + (TokenSaleDist dist, uint256 kind, address addr) = _deployDist(dToken, id); + + dist.transferOwnership(deploymentOwner); + + // invariant: registration succeeds + require(Registry(registry).register(id, kind, addr)); + + return true; + } + + function deployDist(address dToken, bytes32 id, address admin) external onlyRoles(DEPLOY_LEVEL) returns (bool) { + require(admin != address(0), InvalidAddress()); + + (TokenSaleDist dist, uint256 kind, address addr) = _deployDist(dToken, id); + + dist.grantRoles(admin, dist.DISTRIBUTE_LEVEL()); + dist.transferOwnership(deploymentOwner); + + // invariant: registration succeeds + require(Registry(registry).register(id, kind, addr)); + + return true; + } + + function _deployDist(address dToken, bytes32 id) internal returns (TokenSaleDist, uint256, address) { + TokenSaleDist dist = new TokenSaleDist(dToken, id); + uint256 kind = dist.KIND(); + address addr = address(dist); + + emit Deployed(id, kind, addr); + + return (dist, kind, addr); + } + + // ************************ Owner API *********************************************************** + + function setDeploymentOwner(address depOwner) external onlyOwner returns (bool) { + // invariant: deployment owner cannot be 0 address + require(depOwner != address(0), InvalidAddress()); + + deploymentOwner = depOwner; + + return true; + } +} diff --git a/ethereum/src/factory/IFactory.sol b/ethereum/src/factory/IFactory.sol new file mode 100644 index 0000000..29a22ae --- /dev/null +++ b/ethereum/src/factory/IFactory.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +interface IFactory { + // *************************** Events *********************************************************** + + /// @notice Emitted upon successful contract deployment + event Deployed(bytes32 indexed id, uint256 indexed kind, address indexed deployment); + + // *************************** API ************************************************************** + + /// @notice given a minimum amount and a sale id, deploy a new TokenSaleFund and register it + /// @dev ownership is transferred to deploymentOwner on successful deployment and registration + function deployFund(uint256 min, bytes32 id) external returns (bool); + + /// @notice given a minimum amount, a sale id and an address, deploy a new TokenSaleFund, + /// set the address as admin level (commit + remit) role, and register it + /// @dev ownership is transferred to deploymentOwner on successful deployment, assignment, and registration + function deployFund(uint256 min, bytes32 id, address admin) external returns (bool); + + /// @notice given a distribution token and a sale id, deploy a new TokenSaleDist and register it + /// @dev ownership is transferred to deploymentOwner on successful deployment and registration + function deployDist(address dToken, bytes32 id) external returns (bool); + + /// @notice given a distribution token, a token sale id and an address, deploy a new TokenSaleDist, + /// set the address as distribute level role, and register it + /// @dev ownership is transferred to deploymentOwner on successful deployment, assignment, and registration + function deployDist(address dToken, bytes32 id, address admin) external returns (bool); + + /// @notice set the address which will be used as the owner for any new deployments + function setDeploymentOwner(address depOwner) external returns (bool); + + // *********************** Errors ********************************************************* + + /// @dev the given address is invalid + error InvalidAddress(); +} diff --git a/ethereum/src/registry/IRegistry.sol b/ethereum/src/registry/IRegistry.sol new file mode 100644 index 0000000..7531b5e --- /dev/null +++ b/ethereum/src/registry/IRegistry.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +interface IRegistry { + // *************************** Events *********************************************************** + + /// @notice Emitted upon successful registration + event Registered(bytes32 indexed id, uint256 indexed kind, address indexed deployment); + /// @notice Emitted upon successful deregistration + event Deregistered(bytes32 indexed id, uint256 indexed kind); + + // *************************** API ************************************************************** + + /// @notice given an id, kind and deployment address register a deployed contract + function register(bytes32 id, uint256 kind, address deployment) external returns (bool); + /// @notice given an id and a kind deregister a contract + function deregister(bytes32 id, uint256 kind) external returns (bool); + + // ************************** Errors ************************************************************ + + /// @dev if a given address is not an actual deployed contract + error NotContract(address deployment); + /// @dev the given id/kind is already registered + error NotZero(bytes32 id, uint256 kind); +} diff --git a/ethereum/src/registry/Registry.sol b/ethereum/src/registry/Registry.sol new file mode 100644 index 0000000..89dab7c --- /dev/null +++ b/ethereum/src/registry/Registry.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OwnableRoles} from "solady/auth/OwnableRoles.sol"; +import {IRegistry} from "./IRegistry.sol"; +import {isContract} from "shared/Utils.sol"; + +contract Registry is IRegistry, OwnableRoles { + // ************************ State *************************************************************** + + /// @dev non owner role that may register, typically a factory contract + uint256 public constant REGISTER_LEVEL = 2; + /// @dev non owner role that may zero entries in the registry + uint256 public constant DEREGISTER_LEVEL = 4; + /// @dev kinds and addresses for any registered contract per sale id + mapping(bytes32 => mapping(uint256 => address)) public registered; + + constructor() { + _initializeOwner(msg.sender); + } + + // ************************ Admin API *********************************************************** + + function register(bytes32 id, uint256 kind, address deployment) external onlyRoles(REGISTER_LEVEL) returns (bool) { + // invariant: the address is a deployed contract (any deployment sent here should be out of construction phase) + require(isContract(deployment), NotContract(deployment)); + + // invariant: the id, kind composite key has no assigned value + require(registered[id][kind] == address(0), NotZero(id, kind)); + + registered[id][kind] = deployment; + + emit Registered(id, kind, deployment); + + return true; + } + + function deregister(bytes32 id, uint256 kind) external onlyRoles(DEREGISTER_LEVEL) returns (bool) { + registered[id][kind] = address(0); + + emit Deregistered(id, kind); + + return true; + } +} diff --git a/ethereum/src/sale/ITokenSaleDist.sol b/ethereum/src/sale/ITokenSaleDist.sol new file mode 100644 index 0000000..47092aa --- /dev/null +++ b/ethereum/src/sale/ITokenSaleDist.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {DistTotal as Total} from "./Types.sol"; + +interface ITokenSaleDist { + // ********************** Events ******************************************************* + + /// @notice Emitted upon successful distribution + event Distributed(address indexed user, address indexed token, uint256 amount); + + /// @notice Emitted at the conclusion of a batch operation + event Batched(uint256 kind, uint256 succeeded, uint256 failed); + + /// @notice Emitted when contract owner transfers distribution token balance elsewhere + event Transferred(address indexed to, address indexed token, uint256 amount); + + // ********************* API *********************************************************** + + /// @notice the balance of this contract at the known distribution token + function distributionTokenBalance() external returns (uint256); + + /// @notice return the global distribution totals + function totals() external returns (Total memory); + + /// @notice given a user, return their distribution totals + function totals(address user) external returns (Total memory); + + /// @notice given a user and an amount, push the token distribution to them + /// @dev reverts on stopped, invalid user, unauthorized, insufficient amount or safeTransferFrom error + function distribute(address user, uint256 amount) external returns (bool); + + /// @notice given a user and an amount, push the token distribution to them + /// @dev reverts on stopped, unauthorized, nonequivalent list length, invalid user, insufficient amount or safeTransfer error + function distribute(address[] calldata users, uint256[] calldata amounts) external returns (bool); + + /// @notice given a destination address, transfer dist token, available to owner only + /// @dev reverts on unauthorized or safeTransfer error + function transfer(address to, uint256 amount) external returns (bool); + + /// @notice given a destination address, a token and an amount, transfer the token, available to owner only + /// @dev reverts on unauthorized or safeTransfer error + function transfer(address to, address token, uint256 amount) external returns (bool); + + // *********************** Errors ********************************************************* + + /// @dev if the given address is zero or otherwise invalid + error InvalidAddress(); + + /// @dev the contract itself has insufficient funds to distribute the given amount + error InsufficientBalance(uint256 amount, uint256 balance); + + /// @dev the amount given is below the minimum distribution amount + error InsufficientAmount(); + + /// @dev the two lists given to batch distribute do not have equivalent length + error NonEquivalentListLength(); +} diff --git a/ethereum/src/sale/ITokenSaleFund.sol b/ethereum/src/sale/ITokenSaleFund.sol new file mode 100644 index 0000000..cddd709 --- /dev/null +++ b/ethereum/src/sale/ITokenSaleFund.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {SaleTotal as Total} from "./Types.sol"; + +interface ITokenSaleFund { + // ********************** Events ******************************************************* + + /// @notice Emitted upon successful Commit transfer + event Committed(address indexed user, bytes32 indexed option, address indexed token, uint256 amount); + + /// @notice Emitted upon successful Remit transfer + event Remitted(address indexed user, bytes32 indexed option, address indexed token, uint256 amount); + + /// @notice emitted, in-loop, during a partial fail allowing batch + event BatchFail(uint256 kind, address indexed user, bytes32 indexed option, address indexed token, uint256 amount); + + /// @notice Emitted at the conclusion of a batch operation + event Batched(uint256 kind, uint256 succeeded, uint256 failed); + + /// @notice Emitted when contract owner transfers funding token balance elsewhere + event Transferred(address indexed to, address indexed token, uint256 amount); + + /// @notice Emitted when contract owner approves a spender for a given amount + event Approved(address indexed spender, address indexed token, uint256 amount); + + // ********************* API *********************************************************** + + /// @notice return the full balance held by this contract at the given token + function fundingTokenBalance(address token) external view returns (uint256); + + /// @notice return the current global commitment balance (commit sum - remit sum ) for a given token + function commitBalance(address token) external view returns (uint256); + + /// @notice return the current commitment balance (commit sum - remit sum ) for a given user, option and token + function commitBalance(address user, bytes32 option, address token) external view returns (uint256); + + /// @notice given a token return the global commit and remit totals + function totals(address token) external view returns (Total memory); + + /// @notice given a user, option and token return the respective commit and remit totals + function totals(address user, bytes32 option, address token) external view returns (Total memory); + + /// @notice given a user, an option, a token and an amount, transfer funding + /// @dev reverts on stopped, invalid user, unauthorized, insufficient amount or safeTransferFrom error + function commit(address user, bytes32 option, address token, uint256 amount) external returns (bool); + + /// @notice given lists of users, options, tokens and amounts transfer funding + /// @dev reverts on stopped, nonEquivalentListLength or unauthorized + /// @dev logs batch failure on invalid user, insufficient amount or transferFrom error + function commit( + address[] calldata users, + bytes32[] calldata options, + address[] calldata tokens, + uint256[] calldata amounts + ) external returns (bool); + + /// @notice given a user, an option, a token and an amount, return the given amount + /// @dev reverts on stopped, invalid user, unauthorized, insufficient amount, insufficient commitment or safeTransfer error + function remit(address user, bytes32 option, address token, uint256 amount) external returns (bool); + + /// @notice given a list of users, an option, a token and a list of amounts, remit them + /// @dev reverts on stopped, invalid user, nonequivalent list length, unauthorized, insufficient amount, + /// insufficient commitment or safeTransfer error + function remit(address[] calldata users, bytes32 option, address token, uint256[] calldata amounts) + external + returns (bool); + + /// @notice given a spender, a token address and an amount approve the spender at the token + /// @dev reverts on unauthorized or safeApprove error + function approve(address spender, address token, uint256 amount) external returns (bool); + + /// @notice given a destination address, a token and an amount, transfer it, available to owner only + /// @dev reverts on unauthorized or safeTransfer error + function transfer(address to, address token, uint256 amount) external returns (bool); + + // *********************** Errors ********************************************************* + + /// @dev if the given user is zero or the contract address + error InvalidUser(); + + /// @dev the amount given is below the minimum amount accepted for this operation + error InsufficientAmount(uint256 amount, uint256 minimum); + + /// @dev the user does not have sufficient committed funds for the requested remit + error InsufficientCommitment(address user, bytes32 option, address token); + + /// @dev the two lists given to batch remit do not have equivalent length + error NonEquivalentListLength(); + + /// @dev the transferFrom for a supposed commit has failed + error CommitFailed(address user, bytes32 option, address token); +} diff --git a/ethereum/src/sale/TokenSaleDist.sol b/ethereum/src/sale/TokenSaleDist.sol new file mode 100644 index 0000000..24b2c92 --- /dev/null +++ b/ethereum/src/sale/TokenSaleDist.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OwnableRoles} from "solady/auth/OwnableRoles.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {Stopable} from "shared/stopable/Stopable.sol"; +import {isContract} from "shared/Utils.sol"; +import {ITokenSaleDist} from "./ITokenSaleDist.sol"; +import {BatchType, DistTotal as Total} from "./Types.sol"; + +contract TokenSaleDist is ITokenSaleDist, Stopable, OwnableRoles { + // ********************** State ******************************************************** + + /// @dev all distribution contracts are kind 1 + uint256 public constant KIND = 1; + /// @dev updated as features are added/refined + uint256 public constant VERSION = 1; + /// @dev non-owner role that may distribute + uint256 public constant DISTRIBUTE_LEVEL = 2; + /// @dev the minimum amount enforced for distribute operations + uint256 public constant MIN_DIST = 1; + /// @dev the address of the token to be distributed + address public distToken; + /// @dev unique identifier of the sale for which this contract serves + bytes32 public id; + /// @dev distribution totals for the given user + mapping(address => Total) internal _totals; + + constructor(address dToken, bytes32 saleId) { + // invariant: dToken is not zero and is a deployed contract + require(dToken != address(0) && isContract(dToken), InvalidAddress()); + + _initializeOwner(msg.sender); + + distToken = dToken; + id = saleId; + } + + // ********************* Public API **************************************************** + + function distributionTokenBalance() external view returns (uint256) { + return IERC20(distToken).balanceOf(address(this)); + } + + function totals() external view returns (Total memory) { + return _totals[address(this)]; + } + + function totals(address user) external view returns (Total memory) { + return _totals[user]; + } + + // ********************* Admin API ***************************************************** + + function distribute(address user, uint256 amount) + external + started(DISTRIBUTE_LEVEL) + onlyRoles(DISTRIBUTE_LEVEL) + returns (bool) + { + _distribute(user, amount); + + return true; + } + + function distribute(address[] calldata users, uint256[] calldata amounts) + external + started(DISTRIBUTE_LEVEL) + onlyRoles(DISTRIBUTE_LEVEL) + returns (bool) + { + uint256 len = users.length; + // invariant: the lists are of the same length + require(len == amounts.length, NonEquivalentListLength()); + + for (uint256 i = 0; i < len; ++i) { + address user = users[i]; + uint256 amount = amounts[i]; + + _distribute(user, amount); + } + + // distribute operations do not allow partial success at this time + emit Batched(uint256(BatchType.Distribute), len, 0); + + return true; + } + + function _distribute(address user, uint256 amount) internal { + // invariant: user is valid + require(validAddress(user), InvalidAddress()); + + // invariant: amount is >= min + require(amount >= MIN_DIST, InsufficientAmount()); + + // store the user sums + Total storage userData = _totals[user]; + userData.distCount += 1; + userData.distSum += amount; + + // store the contract sums + Total storage conData = _totals[address(this)]; + conData.distCount += 1; + conData.distSum += amount; + + SafeTransferLib.safeTransfer(distToken, user, amount); + + emit Distributed(user, distToken, amount); + } + + // ********************* Owner API ***************************************************** + + function transfer(address to, uint256 amount) external onlyOwner returns (bool) { + SafeTransferLib.safeTransfer(distToken, to, amount); + + emit Transferred(to, distToken, amount); + + return true; + } + + function transfer(address to, address token, uint256 amount) external onlyOwner returns (bool) { + SafeTransferLib.safeTransfer(token, to, amount); + + emit Transferred(to, token, amount); + + return true; + } + + function stop(uint256 level) public override onlyOwner returns (bool) { + return super.stop(level); + } + + // ********************* Utility ******************************************************* + + /// @dev return true if the given address is NOT zero or this + function validAddress(address addr) internal view returns (bool) { + return addr != address(0) && addr != address(this); + } +} diff --git a/ethereum/src/sale/TokenSaleFund.sol b/ethereum/src/sale/TokenSaleFund.sol new file mode 100644 index 0000000..dd584a3 --- /dev/null +++ b/ethereum/src/sale/TokenSaleFund.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OwnableRoles} from "solady/auth/OwnableRoles.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {Stopable} from "shared/stopable/Stopable.sol"; +import {ITokenSaleFund} from "./ITokenSaleFund.sol"; +import {BatchType, SaleTotal as Total} from "./Types.sol"; + +contract TokenSaleFund is ITokenSaleFund, Stopable, OwnableRoles { + // ********************** State ******************************************************** + + /// @dev all funding contracts are kind 0 + uint256 public constant KIND = 0; + /// @dev updated as features are added/refined + uint256 public constant VERSION = 2; + /// @dev non-owner role that may commit + uint256 public constant COMMIT_LEVEL = 2; + /// @dev non-owner role that may remit + uint256 public constant REMIT_LEVEL = 4; + /// @dev the minimum amount enforced for remit operations + uint256 public constant MIN_REMIT = 1; + /// @dev the minimum amount enforced for commit operations + uint256 public minCommit; + /// @dev unique identifier of the sale for which this contract serves + bytes32 public id; + /// @dev commit and remit totals for the given user, option and funding token + mapping(address => mapping(bytes32 => mapping(address => Total))) internal _totals; + + /// @dev the given id is a keccak (equivalent) hash of the appropriate sale identifier + constructor(uint256 min, bytes32 saleId) { + // invariant: min > 0 + require(min > 0, InsufficientAmount(min, 0)); + + _initializeOwner(msg.sender); + minCommit = min; + id = saleId; + } + + // ********************* Public API **************************************************** + + function fundingTokenBalance(address token) external view returns (uint256) { + return IERC20(token).balanceOf(address(this)); + } + + function commitBalance(address token) external view returns (uint256) { + Total memory data = _totals[address(this)][id][token]; + return data.commitSum - data.remitSum; + } + + function commitBalance(address user, bytes32 option, address token) external view returns (uint256) { + Total memory data = _totals[user][option][token]; + return data.commitSum - data.remitSum; + } + + function totals(address token) external view returns (Total memory) { + return _totals[address(this)][id][token]; + } + + function totals(address user, bytes32 option, address token) external view returns (Total memory) { + return _totals[user][option][token]; + } + + // ********************* Admin API ***************************************************** + + function commit(address user, bytes32 option, address token, uint256 amount) + external + started(COMMIT_LEVEL) + onlyRoles(COMMIT_LEVEL) + returns (bool) + { + // invariant: user is valid + require(validUser(user), InvalidUser()); + + // invariant: amount is sufficient + require(amount >= minCommit, InsufficientAmount(amount, minCommit)); + + if (!SafeTransferLib.trySafeTransferFrom(token, user, address(this), amount)) { + revert CommitFailed(user, option, token); + } + + _commit(user, option, token, amount); + + return true; + } + + function commit( + address[] calldata users, + bytes32[] calldata options, + address[] calldata tokens, + uint256[] calldata amounts + ) external started(COMMIT_LEVEL) onlyRoles(COMMIT_LEVEL) returns (bool) { + uint256 batched = 0; + uint256 len = users.length; + + // invariant: the lists are of the same length + require(len == options.length && len == tokens.length && len == amounts.length, NonEquivalentListLength()); + + for (uint256 i = 0; i < len; ++i) { + address user = users[i]; + bytes32 option = options[i]; + address token = tokens[i]; + uint256 amount = amounts[i]; + + // invariants: user valid, amount is sufficient (and tx succeeds) + if ( + validUser(user) && amount >= minCommit + && SafeTransferLib.trySafeTransferFrom(token, user, address(this), amount) + ) { + _commit(user, option, token, amount); + + unchecked { + batched += 1; + } + } else { + emit BatchFail(uint256(BatchType.Commit), user, option, token, amount); + } + } + + emit Batched(uint256(BatchType.Commit), batched, len - batched); + + return true; + } + + /// @dev abstraction for the identical logic used by each commit method + function _commit(address user, bytes32 option, address token, uint256 amount) internal { + Total storage userData = _totals[user][option][token]; + Total storage conData = _totals[address(this)][id][token]; + // store user commit totals for this token + userData.commitCount += 1; + userData.commitSum += amount; + // store contract commit totals for this token + conData.commitCount += 1; + conData.commitSum += amount; + + emit Committed(user, option, token, amount); + } + + function remit(address user, bytes32 option, address token, uint256 amount) + external + started(REMIT_LEVEL) + onlyRoles(REMIT_LEVEL) + returns (bool) + { + _remit(user, option, token, amount); + + return true; + } + + function remit(address[] calldata users, bytes32 option, address token, uint256[] calldata amounts) + external + started(REMIT_LEVEL) + onlyRoles(REMIT_LEVEL) + returns (bool) + { + uint256 len = users.length; + // invariant: the lists are of the same length + require(len == amounts.length, NonEquivalentListLength()); + + for (uint256 i = 0; i < len; ++i) { + address user = users[i]; + uint256 amount = amounts[i]; + + _remit(user, option, token, amount); + } + + // remit operations do not allow partial success at this time + emit Batched(uint256(BatchType.Remit), len, 0); + + return true; + } + + /// @dev abstraction for the identical logic used by each remit method + function _remit(address user, bytes32 option, address token, uint256 amount) internal { + // invariant: user is valid + require(validUser(user), InvalidUser()); + // invariant: amount is non zero + require(amount >= MIN_REMIT, InsufficientAmount(amount, MIN_REMIT)); + + // invariant: the user has sufficient commitment + Total storage userData = _totals[user][option][token]; + require(userData.commitSum - userData.remitSum >= amount, InsufficientCommitment(user, option, token)); + + // store the user sums + userData.remitCount += 1; + userData.remitSum += amount; + // store the contract sums + Total storage conData = _totals[address(this)][id][token]; + conData.remitCount += 1; + conData.remitSum += amount; + + SafeTransferLib.safeTransfer(token, user, amount); + + emit Remitted(user, option, token, amount); + } + + // ********************* Owner API ***************************************************** + + function approve(address spender, address token, uint256 amount) external onlyOwner returns (bool) { + SafeTransferLib.safeApproveWithRetry(token, spender, amount); + + emit Approved(spender, token, amount); + + return true; + } + + /// @dev the to address will have been confirmed to exist by this point + function transfer(address to, address token, uint256 amount) external onlyOwner returns (bool) { + SafeTransferLib.safeTransfer(token, to, amount); + + emit Transferred(to, token, amount); + + return true; + } + + function stop(uint256 level) public override onlyOwner returns (bool) { + return super.stop(level); + } + + // ********************* Utility ******************************************************* + + /// @dev return true if the given address is NOT zero or this + function validUser(address user) internal view returns (bool) { + return user != address(0) && user != address(this); + } +} diff --git a/ethereum/src/sale/Types.sol b/ethereum/src/sale/Types.sol new file mode 100644 index 0000000..e2de989 --- /dev/null +++ b/ethereum/src/sale/Types.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +/// @dev the types of batch operations +enum BatchType { + Commit, + Remit, + Distribute +} + +/// @dev total amounts of commits and remits +struct SaleTotal { + uint256 commitCount; + uint256 commitSum; + uint256 remitCount; + uint256 remitSum; +} + +struct DistTotal { + uint256 distCount; + uint256 distSum; +} diff --git a/ethereum/src/sale/flying-tulip/FlyingTulipFund.sol b/ethereum/src/sale/flying-tulip/FlyingTulipFund.sol new file mode 100644 index 0000000..6b21e4d --- /dev/null +++ b/ethereum/src/sale/flying-tulip/FlyingTulipFund.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.30; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; +import {IPutManager, IFlyingTulipFund} from "./IFlyingTulipFund.sol"; +import {SaleTotal as Total} from "sale/Types.sol"; + +contract FlyingTulipFund is IFlyingTulipFund, TokenSaleFund { + // ************************ State ************************************************ + + /// @dev proof amount sent to FT in invest call + uint256 private constant PROOF_AMOUNT = 0; + /// @dev the type issued with batch related events + uint256 public ftBatchType; + /// @dev a WL sent to FT on invest call, provided by FT + bytes32[] private _proofWl; + /// @dev the FT PutManager whose invest method we will be calling + address public putManagerAddress; + /// @dev flag which allows owner to control investFor commit balance checks + bool public commitBalanceOverride; + + constructor(uint256 min, bytes32 saleId, address pma) TokenSaleFund(min, saleId) { + // ivariant: put manager address is not zero + require(pma != address(0), PutManagerIsZero()); + + ftBatchType = uint256(saleId); + putManagerAddress = pma; + } + + // ************************ Public API ******************************************* + + function isProofWlSet() external view returns (bool) { + return _proofWl.length > 0; + } + + // ************************ Admin API ******************************************* + + function investFor(address user, bytes32 option, address token, uint256 amount) + external + started(REMIT_LEVEL) + onlyRoles(REMIT_LEVEL) + returns (bool) + { + // invariant: Wl has been set + require(_proofWl.length > 0, ProofWlNotSet()); + // invariant: amount >= minCommit + require(amount >= minCommit, InsufficientAmount(amount, minCommit)); + // invariant: user has sufficient commit balance (when not overridden) + if (!commitBalanceOverride) { + Total memory data = _totals[user][option][token]; + require(data.commitSum - data.remitSum >= amount, InsufficientCommitment(user, option, token)); + } + + // dev: we are assuming that an approve call has already been made to the putManagerAddress + IPutManager(putManagerAddress).invest(token, amount, user, PROOF_AMOUNT, _proofWl); + + emit Invested(user, token, amount); + + return true; + } + + function investFor( + address[] calldata users, + bytes32[] calldata options, + address[] calldata tokens, + uint256[] calldata amounts + ) external started(REMIT_LEVEL) onlyRoles(REMIT_LEVEL) returns (bool) { + // invariant: Wl has been set + require(_proofWl.length > 0, ProofWlNotSet()); + + uint256 batched = 0; + uint256 len = users.length; + + // invariant: the lists are of the same length + require(len == options.length && len == tokens.length && len == amounts.length, NonEquivalentListLength()); + + for (uint256 i = 0; i < len; ++i) { + address user = users[i]; + bytes32 option = options[i]; + address token = tokens[i]; + uint256 amount = amounts[i]; + + Total memory data = _totals[user][option][token]; + + // invariants: amount >= minCommit && user has sufficient commit balance (when not overridden) + if (amount >= minCommit && (commitBalanceOverride || (data.commitSum - data.remitSum >= amount))) { + try IPutManager(putManagerAddress).invest(token, amount, user, PROOF_AMOUNT, _proofWl) { + unchecked { + batched += 1; + } + + emit Invested(user, token, amount); + } catch { + emit BatchFail(ftBatchType, user, option, token, amount); + } + } else { + emit BatchFail(ftBatchType, user, option, token, amount); + } + } + + emit Batched(ftBatchType, batched, len - batched); + + return true; + } + + // ***************** Owner API ************************************************* + + function setProofWl(bytes32[] calldata pwl) external onlyOwner returns (bool) { + _proofWl = pwl; + return true; + } + + function toggleCommitBalanceOverride() external onlyOwner returns (bool) { + commitBalanceOverride = !commitBalanceOverride; + + // TODO rework this contract if revisiting for new stop mechanism + // emit Toggled(!commitBalanceOverride, commitBalanceOverride); + + return true; + } +} diff --git a/ethereum/src/sale/flying-tulip/IFlyingTulipFund.sol b/ethereum/src/sale/flying-tulip/IFlyingTulipFund.sol new file mode 100644 index 0000000..094e05d --- /dev/null +++ b/ethereum/src/sale/flying-tulip/IFlyingTulipFund.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.30; + +/// @notice the interface for the flying tulip contract we will be calling +interface IPutManager { + function invest(address token, uint256 amount, address recipient, uint256 proofAmount, bytes32[] calldata proofWl) + external + returns (uint256); +} + +interface IFlyingTulipFund { + // ********************* Events ******************************************************************* + + /// @notice emitted upon successful invest call + event Invested(address indexed user, address indexed token, uint256 amount); + + // ******************** API *********************************************************************** + + /// @notice convenience method to see if the proofWl has been set + function isProofWlSet() external view returns (bool); + + /// @notice given a user, an option, a funding token and an amount, call FT invest on that user's behalf + /// @dev reverts on stopped, proof WL not set, insufficient amount and insufficient commit balance (if not overridden) + function investFor(address user, bytes32 option, address token, uint256 amount) external returns (bool); + + /// @notice given lists of users, funding tokens and amounts, call FT invest on those user's behalf + /// @dev reverts on stopped, non equivalent list length and proof WL not set + /// @dev logs batch fail on insufficient amount, insufficient commit balance (if not overridden) and investFor fail + function investFor( + address[] calldata users, + bytes32[] calldata options, + address[] calldata tokens, + uint256[] calldata amounts + ) external returns (bool); + + /// @notice setter for the flying tulip proofWl, available to owner only + function setProofWl(bytes32[] calldata pwl) external returns (bool); + + // ******************** Errors ******************************************************************** + + /// @dev the put manager address cannot be zero + error PutManagerIsZero(); + /// @dev the FT proofWl was not set + error ProofWlNotSet(); +} diff --git a/ethereum/src/swap/ITokenSwap.sol b/ethereum/src/swap/ITokenSwap.sol new file mode 100644 index 0000000..38f5213 --- /dev/null +++ b/ethereum/src/swap/ITokenSwap.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {Preview, SwapTotal as Total} from "./Types.sol"; + +interface ITokenSwap { + // ********************* Events ************************************************** + + /// @notice Emitted upon a successful Swap + event Swapped( + address indexed user, + address indexed inputToken, + address indexed outputToken, + uint256 inputAmount, + uint256 fee, + uint256 outputAmount + ); + + /// @notice Emitted when contract owner transfers input token balance (from fees) elsewhere + event Transferred(address indexed to, address indexed token, uint256 amount); + + /// @notice Emitted when contract owner updates the basis point fee value + event BpsUpdated(uint256 prev, uint256 next); + + // ********************* API ***************************************************** + + /// @notice return this contract's balance of the given token + /// @dev used for checking balances of input tokens + function tokenBalance(address token) external view returns (uint256); + + /// @notice return this contract's balance of the output token + function outputTokenBalance() external view returns (uint256); + + /// @notice given an input token address, return global input and output totals + function totals(address token) external view returns (Total memory); + + /// @notice given a user return their input and output totals + function totals(address user, address token) external view returns (Total memory); + + /// @notice given a total amount calculate and return (fee, adjustedAmount) + function fee(uint256 amount) external view returns (uint256, uint256); + + /// @notice given an amount calculate and return the set basis point percentage of it + function bpp(uint256 amount) external view returns (uint256); + + /// @notice returns a boolean reflecting the ability of the given address to participate in this swap + function authorized(address user) external view returns (bool); + + /// @notice given a token address and the intended input amount, return a hydrated Preview + /// @dev actual-amount-spent could be less than given amount in some scenarios + /// @dev it is expected that `token` implement ERC20.decimals() metadata method + function preview(address token, uint256 amount) external view returns (Preview memory); + + /// @notice given a token, an amount and a slippage protection minimum, perform a swap + /// @dev the base contract will calculate and return a fee amount, if any + /// @return the amount of output token rewarded + function swap(address token, uint256 amount, uint256 slip) external returns (uint256); + + /// @notice given an address and an amount transfer output token, available to owner only + /// @dev reverts on safeTransfer error + function transfer(address to, uint256 amount) external returns (bool); + + /// @notice given a token, an address and an amount transfer the token, available to owner only + /// @dev reverts on safeTransfer error + function transfer(address to, address token, uint256 amount) external returns (bool); + + /// @notice set the given basis points as the bps needed for fee calculation, available to owner only + /// @dev use of the fee mechanism is optional and may be written into a child's `swap` method if so chosen + /// @dev reverts on unauthorized or basis points being set to 100% or above + function setBps(uint256 points) external returns (bool); + + // ********************* Errors ************************************************** + + /// @dev if the given addr is zero or this contract address + error InvalidAddress(); + /// @dev if a given amount is not valid for the requested operation + error InvalidAmount(); + /// @dev if a given amount is specifically less than expected + error InsufficientAmount(); + /// @dev the swap call has failed + error SwapFailed(address user, address token); +} diff --git a/ethereum/src/swap/TokenSwap.sol b/ethereum/src/swap/TokenSwap.sol new file mode 100644 index 0000000..70ede5b --- /dev/null +++ b/ethereum/src/swap/TokenSwap.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {Ownable} from "solady/auth/OwnableRoles.sol"; +import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {Operable} from "shared/operable/Operable.sol"; +import {isContract} from "shared/Utils.sol"; +import {ITokenSwap} from "./ITokenSwap.sol"; +import {Preview, SwapTotal as Total} from "./Types.sol"; + +abstract contract TokenSwap is ITokenSwap, Operable, Ownable { + /// @dev all swap contracts are kind 2 + uint16 public constant KIND = 2; + uint16 public constant VERSION = 1; + /// @dev constant representing the stopable act of performing a swap + uint32 public constant SWAP_LEVEL = 2; + + /// @dev constant representing the basis point equivalent of 100% + uint256 public constant ONE_HUNDRED_P = 10000; + /// @dev the amount (if any) of an input token fee charged per swap (basis point format) + uint256 public bps; + + address public outputToken; + bytes32 public id; + + // @dev user => token => total + mapping(address => mapping(address => Total)) internal _totals; + + constructor(address token, bytes32 swapId) { + // invariant: output address is valid smart contract + require(validAddr(token) && isContract(token), InvalidAddress()); + + _initializeOwner(msg.sender); + + outputToken = token; + id = swapId; + } + + // **************** Public API *************************************************** + + function tokenBalance(address token) external view returns (uint256) { + return IERC20(token).balanceOf(address(this)); + } + + function outputTokenBalance() external view returns (uint256) { + return IERC20(outputToken).balanceOf(address(this)); + } + + function totals(address token) external view returns (Total memory) { + return _totals[address(this)][token]; + } + + function totals(address user, address token) external view returns (Total memory) { + return _totals[user][token]; + } + + function fee(uint256 amount) public view returns (uint256, uint256) { + // if a bps has been set, validate the input and calculate the actual fee + if (bps > 0) { + // floor(a * b / c) as the adjusted amount + uint256 adj = FixedPointMathLib.mulDiv(amount, ONE_HUNDRED_P, ONE_HUNDRED_P + bps); + // returns fee as amount - adjusted amount, along with said adjusted amount + return (amount - adj, adj); + } else { + // a zero bps is "no fee" + return (bps, amount); + } + } + + function bpp(uint256 amount) public view returns (uint256) { + return bps > 0 ? FixedPointMathLib.mulDiv(amount, bps, ONE_HUNDRED_P) : bps; + } + + function authorized(address user) external view virtual returns (bool); + + function preview(address token, uint256 amount) external view virtual returns (Preview memory); + + function swap(address token, uint256 amount, uint256 slip) external virtual returns (uint256); + + // **************** Owner API **************************************************** + + function setBps(uint256 points) external onlyOwner returns (bool) { + // invariant: points cannot meet or exceed 100% + require(points < ONE_HUNDRED_P, InvalidAmount()); + + emit BpsUpdated(bps, points); + + bps = points; + + return true; + } + + function transfer(address to, uint256 amount) public onlyOwner returns (bool) { + return transfer(to, outputToken, amount); + } + + function transfer(address to, address token, uint256 amount) public onlyOwner returns (bool) { + SafeTransferLib.safeTransfer(token, to, amount); + emit Transferred(to, token, amount); + return true; + } + + function pause(uint32 level) public override onlyOwner returns (bool) { + return super.pause(level); + } + + function stop() public override onlyOwner returns (bool) { + return super.stop(); + } + + // ***************** Utility ***************************************************** + + /// @dev return true if the given address is NOT zero or this + function validAddr(address addr) internal view returns (bool) { + return addr != address(0) && addr != address(this); + } +} diff --git a/ethereum/src/swap/Types.sol b/ethereum/src/swap/Types.sol new file mode 100644 index 0000000..f72bf65 --- /dev/null +++ b/ethereum/src/swap/Types.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +/// @notice total amounts of inputs and outputs, including fees collected and a count of all swaps +struct SwapTotal { + uint256 inputSum; + uint256 feeSum; + uint256 outputSum; + uint256 count; +} + +/// @notice input, fee and output amounts, returned from the preview method +struct Preview { + uint256 input; + uint256 fee; + uint256 output; +} diff --git a/ethereum/src/swap/superstate/IDip.sol b/ethereum/src/swap/superstate/IDip.sol new file mode 100644 index 0000000..b3e35e6 --- /dev/null +++ b/ethereum/src/swap/superstate/IDip.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {Market} from "./Types.sol"; + +/// @notice the minimal interface for working with Superstate Dip implementing contracts +interface IDip { + /// @notice getter for a market given its marketId + function markets(bytes32 marketId) external view returns (Market memory); + /// @dev returns the actual amount that would be taken (could be less than given depending on supply) and payout + function calculateOutput(bytes32 marketId, uint256 amount, uint8 decimals) external view returns (uint256, uint256); +} diff --git a/ethereum/src/swap/superstate/IDippable.sol b/ethereum/src/swap/superstate/IDippable.sol new file mode 100644 index 0000000..70ad008 --- /dev/null +++ b/ethereum/src/swap/superstate/IDippable.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +/// @notice minimal interface for working with Superstate Dippable contracts +interface IDippable { + /// @notice return the address of the Dip implementation + function dipContract() external view returns (address); + /// @notice given an address, return its allowlist state + function isAllowed(address user) external view returns (bool); + /// @notice call the Superstate Dippable method to perform the swap + /// @param marketId: superstate market ... + /// @param amount: the input amount (which fee may be taken from) + /// @param slip: slippage floor + /// @param token: the payment token + /// @dev the market will have a known recipient address, the msg.sender of this call must have approved/permitted that address + function buyTheDip(bytes32 marketId, uint256 amount, uint256 slip, address token) external returns (uint256); +} diff --git a/ethereum/src/swap/superstate/Superstate.sol b/ethereum/src/swap/superstate/Superstate.sol new file mode 100644 index 0000000..5a40438 --- /dev/null +++ b/ethereum/src/swap/superstate/Superstate.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; +import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; +import {isContract} from "shared/Utils.sol"; +import {State, Status} from "shared/operable/Types.sol"; +import {TokenSwap} from "swap/TokenSwap.sol"; +import {Preview, SwapTotal as Total} from "swap/Types.sol"; +import {IDip} from "./IDip.sol"; +import {IDippable} from "./IDippable.sol"; +import {Market, MarketState} from "./Types.sol"; + +contract Superstate is TokenSwap, ReentrancyGuard { + /// @dev identifier of the market this integration is serving + bytes32 public marketId; + /// @dev superstate allows these tokens as input + mapping(address => bool) public inputTokens; + + /// @param token: address of the swap output token + /// @param mktId: the superstate market id this integration is for + /// @param swapId: the coinlist id for this integration + constructor(address token, bytes32 mktId, bytes32 swapId) TokenSwap(token, swapId) { + marketId = mktId; + // USDC is a known valid input for this integration + inputTokens[0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48] = true; + } + + // **************** Public API *************************************************** + + function status() public view override returns (Status memory) { + // get any status set by us + Status memory stat = super.status(); + // if any is present, just short circuit here + if (stat.state != State.Active) { + return stat; + } else { + // check the market state from superstate + address con = IDippable(outputToken).dipContract(); + Market memory mkt = IDip(con).markets(marketId); + if (mkt.state != MarketState.Active) { + // we consider initialized as paused + if (mkt.state == MarketState.Initialized || mkt.state == MarketState.Paused) { + stat.state = State.Paused; + } else { + // any other market states we consider stopped + stat.state = State.Stopped; + } + } + + return stat; + } + } + + function authorized(address user) external view override returns (bool) { + return IDippable(outputToken).isAllowed(user); + } + + function preview(address token, uint256 amount) public view override returns (Preview memory) { + uint256 _fee; + + // get the correct fee to amount ratio + (_fee, amount) = super.fee(amount); + + // check that amount can be spent + address con = IDippable(outputToken).dipContract(); + (uint256 input, uint256 yield) = IDip(con).calculateOutput(marketId, amount, IERC20(token).decimals()); + + // not enough supply for desired input, revise it down + if (input < amount) { + // with a revised input, we can simply calculate our basis point percentage and make it inclusive + _fee = super.bpp(input); + } + + return Preview(input, _fee, yield); + } + + function swap(address token, uint256 amount, uint256 slip) + external + override + active(SWAP_LEVEL) + nonReentrant + returns (uint256) + { + // invariant: the given token address is an approved input + require(inputTokens[token], InvalidAddress()); + // invariant: the msg.sender is authorized + require(IDippable(outputToken).isAllowed(msg.sender), Unauthorized()); + + Preview memory pre = preview(token, amount); + + // pull input and fee from user + if (!SafeTransferLib.trySafeTransferFrom(token, msg.sender, address(this), (pre.input + pre.fee))) { + revert SwapFailed(msg.sender, token); + } + + // bookkeeping for the caller and global state input and fee + Total storage userData = _totals[msg.sender][token]; + Total storage conData = _totals[address(this)][token]; + + userData.count += 1; + userData.inputSum += pre.input; + userData.feeSum += pre.fee; + conData.count += 1; + conData.inputSum += pre.input; + conData.feeSum += pre.fee; + + // approve the output token contract to pull our inputToken, reverts on fail + SafeTransferLib.safeApproveWithRetry(token, outputToken, pre.input); + + // get this contract's current balance of the input token, we will compare post buyTheDip to track spend + uint256 inBal = IERC20(token).balanceOf(address(this)); + + // buy the dip returns the amount minted + uint256 minted = IDippable(outputToken).buyTheDip(marketId, pre.input, slip, token); + + // invariant: the amount minted >= the user defined slippage protection + require(minted >= slip, InsufficientAmount()); + + // invariant: the actual spend matches our approved amount + require(inBal - IERC20(token).balanceOf(address(this)) == pre.input, SwapFailed(msg.sender, token)); + + // transfer the caller their minted tokens + SafeTransferLib.safeTransfer(outputToken, msg.sender, minted); + + // bookkeeping for the caller and global output + userData.outputSum += minted; + // NOTE: this is simply a sum of all tokens minted and transferred, this token's balanceOf (output token) should be 0 + conData.outputSum += minted; + + emit Swapped(msg.sender, token, outputToken, pre.input, pre.fee, minted); + + return minted; + } + + // **************** Owner API **************************************************** + + /// @notice given a token address and a boolean representing whitelist status, set those values + /// @dev reverts if address is invalid + function setInputToken(address token, bool val) external onlyOwner returns (bool) { + // invariant: address is valid + require(validAddr(token) && isContract(token), InvalidAddress()); + + emit InputTokenUpdated(token, inputTokens[token], val); + + inputTokens[token] = val; + + return true; + } + + // **************** Events **************************************************** + + /// @notice emitted on any value change to the input tokens whitelist status + event InputTokenUpdated(address indexed token, bool prev, bool next); +} diff --git a/ethereum/src/swap/superstate/Types.sol b/ethereum/src/swap/superstate/Types.sol new file mode 100644 index 0000000..7632c5f --- /dev/null +++ b/ethereum/src/swap/superstate/Types.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +enum MarketState { + Initialized, // Created, not yet active + Active, // Live for purchases (one per instrument) + Paused, // Suspended, reactivatable + Closed, // Complete (target reached) + Cancelled // Terminated by admin +} + +struct Market { + MarketState state; +} diff --git a/ethereum/test/factory/DefaultState.t.sol b/ethereum/test/factory/DefaultState.t.sol new file mode 100644 index 0000000..05ca901 --- /dev/null +++ b/ethereum/test/factory/DefaultState.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {Registry} from "registry/Registry.sol"; +import {Factory} from "factory/Factory.sol"; + +contract FactoryDefault is Test { + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + function testRevertZeroRegAddr() public { + vm.expectRevert(); + new Factory(address(0), BOB); + } + + function testRevertNotContract() public { + vm.expectRevert(); + new Factory(SOMEONE, BOB); + } + + function testRevertZeroDepOwner() public { + Registry reg = new Registry(); + + vm.expectRevert(); + new Factory(address(reg), address(0)); + } + + function testRegistryaddr() public { + Registry reg = new Registry(); + Factory fact = new Factory(address(reg), BOB); + + assertEq(fact.registry(), address(reg)); + } + + function testDepOwneraddr() public { + Registry reg = new Registry(); + Factory fact = new Factory(address(reg), BOB); + + assertEq(fact.deploymentOwner(), BOB); + } + + function testRevertNotOwner() public { + Registry reg = new Registry(); + Factory fact = new Factory(address(reg), BOB); + + vm.prank(BOB); + vm.expectRevert(); + fact.setDeploymentOwner(SOMEONE); + } + + function testRevertSetDepOwner() public { + Registry reg = new Registry(); + Factory fact = new Factory(address(reg), BOB); + + vm.expectRevert(); + fact.setDeploymentOwner(address(0)); + } + + function testSetDepOwner() public { + Registry reg = new Registry(); + Factory fact = new Factory(address(reg), BOB); + + fact.setDeploymentOwner(SOMEONE); + + assertEq(fact.deploymentOwner(), SOMEONE); + } +} diff --git a/ethereum/test/factory/DeployDist.t.sol b/ethereum/test/factory/DeployDist.t.sol new file mode 100644 index 0000000..0173701 --- /dev/null +++ b/ethereum/test/factory/DeployDist.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {Registry} from "registry/Registry.sol"; +import {Factory} from "factory/Factory.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; +import {TokenSaleDist} from "sale/TokenSaleDist.sol"; + +contract DeployDist is Test { + TestToken public token; + Registry public reg; + Factory public fact; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + bytes4 public constant REGISTER_SELECTOR = bytes4(keccak256("register(bytes32,uint256,address)")); + + function setUp() public { + // intentionally a bit of an integration test + token = new TestToken("TestToken", "TEST"); + reg = new Registry(); + // factory expects a registry address at construction + fact = new Factory(address(reg), BOB); + // factory needs to be assigned registrar + reg.grantRoles(address(fact), reg.REGISTER_LEVEL()); + // must be a deployer level + fact.grantRoles(ADMIN, fact.DEPLOY_LEVEL()); + } + + function testRevertNotDeployer() public { + vm.prank(SOMEONE); + vm.expectRevert(); + fact.deployDist(address(token), SALE_ID); + } + + function testRevertDtoken() public { + vm.prank(ADMIN); + vm.expectRevert(); + fact.deployDist(address(0), SALE_ID); + } + + function testRevertAtRegistry() public { + // the call fails at the registry for some reason... + vm.mockCall(address(reg), abi.encodeWithSelector(REGISTER_SELECTOR), abi.encode(false)); + + vm.prank(ADMIN); + vm.expectRevert(); + + fact.deployDist(address(token), SALE_ID); + + // nothing registered + address addr = reg.registered(SALE_ID, 1); + assertEq(addr, address(0)); + } + + function testDeploy() public { + vm.prank(ADMIN); + fact.deployDist(address(token), SALE_ID); + + // the address will be available at the registry + address addr = reg.registered(SALE_ID, 1); + // shouldn't be zero + assertNotEq(addr, address(0)); + + // owner is properly set + assertEq(TokenSaleDist(addr).owner(), BOB); + + // no admin registered in this use case + assertEq(TokenSaleDist(addr).rolesOf(ADMIN), 0); + } + + function testRevertAdminZero() public { + vm.prank(ADMIN); + vm.expectRevert(); + fact.deployDist(address(token), SALE_ID, address(0)); + } + + function testDeployAndGrantRole() public { + vm.prank(ADMIN); + fact.deployDist(address(token), SALE_ID, ADMIN); + + // the address will be available at the registry + address addr = reg.registered(SALE_ID, 1); + + // shouldn't be zero + assertNotEq(addr, address(0)); + + // owner is properly set + assertEq(TokenSaleDist(addr).owner(), BOB); + + // admin is registered in this use case + assertEq(TokenSaleDist(addr).rolesOf(ADMIN), 2); + } + + // should be able to deploy fund and dist for same sale id, and be correctly registered (id: kind: addr) + function testDeployFundAndDist() public { + vm.startPrank(ADMIN); + fact.deployFund(100, SALE_ID, ADMIN); + fact.deployDist(address(token), SALE_ID, ADMIN); + + // both addrs registered + address depFund = reg.registered(SALE_ID, 0); + address depDist = reg.registered(SALE_ID, 1); + + assertNotEq(depFund, address(0)); + assertNotEq(depDist, address(0)); + assertEq(TokenSaleFund(depFund).owner(), BOB); + assertEq(TokenSaleDist(depDist).owner(), BOB); + assertEq(TokenSaleFund(depFund).rolesOf(ADMIN), 6); + assertEq(TokenSaleDist(depDist).rolesOf(ADMIN), 2); + } +} diff --git a/ethereum/test/factory/DeployFund.t.sol b/ethereum/test/factory/DeployFund.t.sol new file mode 100644 index 0000000..83d0d8f --- /dev/null +++ b/ethereum/test/factory/DeployFund.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {Registry} from "registry/Registry.sol"; +import {Factory} from "factory/Factory.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; + +contract DeployFund is Test { + Registry public reg; + Factory public fact; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + uint256 public constant MINIMUM = 100; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + bytes4 public constant REGISTER_SELECTOR = bytes4(keccak256("register(bytes32,uint256,address)")); + + function setUp() public { + // intentionally a bit of an integration test + reg = new Registry(); + // factory expects a registry address at construction + fact = new Factory(address(reg), BOB); + // factory needs to be assigned registrar + reg.grantRoles(address(fact), reg.REGISTER_LEVEL()); + // must be a deployer level + fact.grantRoles(ADMIN, fact.DEPLOY_LEVEL()); + } + + function testRevertNotDeployer() public { + vm.prank(SOMEONE); + vm.expectRevert(); + fact.deployFund(MINIMUM, SALE_ID); + } + + function testRevertMinAmount() public { + vm.prank(ADMIN); + vm.expectRevert(); + fact.deployFund(0, SALE_ID); + } + + function testRevertAtRegistry() public { + // the call fails at the registry for some reason... + vm.mockCall(address(reg), abi.encodeWithSelector(REGISTER_SELECTOR), abi.encode(false)); + + vm.prank(ADMIN); + vm.expectRevert(); + + fact.deployFund(MINIMUM, SALE_ID); + + // nothing registered + address addr = reg.registered(SALE_ID, 0); + assertEq(addr, address(0)); + } + + function testDeploy() public { + vm.prank(ADMIN); + fact.deployFund(MINIMUM, SALE_ID); + + // the address will be available at the registry + address addr = reg.registered(SALE_ID, 0); + // shouldn't be zero + assertNotEq(addr, address(0)); + + // owner is properly set + assertEq(TokenSaleFund(addr).owner(), BOB); + + // no admin registered in this use case + assertEq(TokenSaleFund(addr).rolesOf(ADMIN), 0); + } + + function testRevertAdminZero() public { + vm.prank(ADMIN); + vm.expectRevert(); + fact.deployFund(MINIMUM, SALE_ID, address(0)); + } + + function testDeployAndGrantRole() public { + vm.prank(ADMIN); + fact.deployFund(MINIMUM, SALE_ID, ADMIN); + + // the address will be available at the registry + address addr = reg.registered(SALE_ID, 0); + + // shouldn't be zero + assertNotEq(addr, address(0)); + + // owner is properly set + assertEq(TokenSaleFund(addr).owner(), BOB); + + // admin is registered in this use case + assertEq(TokenSaleFund(addr).rolesOf(ADMIN), 6); + } +} diff --git a/ethereum/test/flying-tulip/DefaultFundState.t.sol b/ethereum/test/flying-tulip/DefaultFundState.t.sol new file mode 100644 index 0000000..788d43e --- /dev/null +++ b/ethereum/test/flying-tulip/DefaultFundState.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {FlyingTulipFund} from "flying-tulip/FlyingTulipFund.sol"; + +contract FlyingTulipFundDefault is Test { + FlyingTulipFund public fund; + bytes32 public constant SALE_ID = keccak256("abc-123"); + address public constant PUT_MANAGER = 0x5050505050505050505050505050505050505050; + bytes32 public constant THIS = keccak256("abc-456"); + bytes32 public constant THAT = keccak256("abc-789"); + bytes32[] public pwl = [THIS, THAT]; + + function setUp() public { + fund = new FlyingTulipFund(100, SALE_ID, PUT_MANAGER); + } + + function testIsOwned() public view { + // all forge test contracts are deployed from 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 + assertEq(fund.owner(), 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496); + } + + function testIsNotStopped() public view { + assertEq(fund.stopped(), 0); + } + + function testIsNotOverridden() public view { + assertEq(fund.commitBalanceOverride(), false); + } + + function testSaleId() public view { + assertEq(fund.id(), SALE_ID); + } + + function testSaleMinCommit() public view { + assertEq(fund.minCommit(), 100); + } + + function testPutManagerAddr() public view { + assertEq(fund.putManagerAddress(), PUT_MANAGER); + } + + function testProofWlisNotSet() public view { + assertEq(fund.isProofWlSet(), false); + } + + function testRevertWhenNotOwner() public { + vm.prank(PUT_MANAGER); + vm.expectRevert(); + + fund.setProofWl(pwl); + } + + function testCanSetProofWl() public { + fund.setProofWl(pwl); + assertEq(fund.isProofWlSet(), true); + } + + function testCanOverrideCommitBalance() public { + fund.toggleCommitBalanceOverride(); + assertEq(fund.commitBalanceOverride(), true); + fund.toggleCommitBalanceOverride(); + assertEq(fund.commitBalanceOverride(), false); + } +} diff --git a/ethereum/test/flying-tulip/InvestFor.t.sol b/ethereum/test/flying-tulip/InvestFor.t.sol new file mode 100644 index 0000000..f4cc87b --- /dev/null +++ b/ethereum/test/flying-tulip/InvestFor.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {FlyingTulipFund} from "flying-tulip/FlyingTulipFund.sol"; + +contract FlyingTulipInvestFor is Test { + FlyingTulipFund public fund; + address public constant PUT_MANAGER = 0x9090909090909090909090909090909090909090; + address public constant F_TOKEN_1 = 0x4040404040404040404040404040404040404040; + address public constant F_TOKEN_2 = 0x5050505050505050505050505050505050505050; + address public constant ALICE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + address public constant ADMIN = 0x8080808080808080808080808080808080808080; + bytes32 public constant SALE_ID = keccak256("abc-123"); + bytes32 public constant OPT_ONE = keccak256("opt-12345"); + bytes32 public constant OPT_TWO = keccak256("opt-67890"); + bytes32 public constant THIS = keccak256("abc-456"); + bytes32 public constant THAT = keccak256("abc-789"); + bytes32[] public pwl = [THIS, THAT]; + address[] public users = [BOB, ALICE]; + address[] public tooManyUsers = [BOB, ALICE, ADMIN]; + bytes32[] public options = [OPT_ONE, OPT_TWO]; + address[] public tokens = [F_TOKEN_1, F_TOKEN_2]; + uint256[] public amounts = [100, 200]; + uint256[] public wrongAmounts = [10, 200]; + + bytes4 public constant TRANSFER_FROM_SELECTOR = bytes4(keccak256("transferFrom(address,address,uint256)")); + bytes4 public constant INVEST_SELECTOR = bytes4(keccak256("invest(address,uint256,address,uint256,bytes32[])")); + + function setUp() public { + fund = new FlyingTulipFund(100, SALE_ID, PUT_MANAGER); + fund.grantRoles(ADMIN, fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + } + + function testRevertWhenStopped() public { + // flying tulip used remit level for invest for + fund.stop(fund.REMIT_LEVEL()); + vm.expectRevert(); + + fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 100); + } + + function testRevertWhenNotRemitter() public { + vm.prank(ALICE); + vm.expectRevert(); + + fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 100); + } + + function testRevertWlNotSet() public { + vm.prank(ADMIN); + vm.expectRevert(); + + fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 100); + } + + function testBatchRevertWlNotSet() public { + vm.prank(ADMIN); + vm.expectRevert(); + + fund.investFor(users, options, tokens, amounts); + } + + function testRevertMinAmt() public { + fund.setProofWl(pwl); + + vm.prank(ADMIN); + vm.expectRevert(); + + fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 10); + } + + function testRevertMinCommittment() public { + fund.setProofWl(pwl); + + vm.prank(ADMIN); + vm.expectRevert(); + + fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 100); + } + + function testMinCommittmentOverride() public { + fund.setProofWl(pwl); + fund.toggleCommitBalanceOverride(); + + vm.mockCall(PUT_MANAGER, abi.encodeWithSelector(INVEST_SELECTOR), abi.encode(1)); + + vm.prank(ADMIN); + assertEq(fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 100), true); + } + + function testInvestFor() public { + fund.setProofWl(pwl); + + // need a commit.. + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(PUT_MANAGER, abi.encodeWithSelector(INVEST_SELECTOR), abi.encode(1)); + + vm.startPrank(ADMIN); + assertEq(fund.commit(ALICE, OPT_ONE, F_TOKEN_1, 100), true); + assertEq(fund.commitBalance(ALICE, OPT_ONE, F_TOKEN_1), 100); + + assertEq(fund.investFor(ALICE, OPT_ONE, F_TOKEN_1, 100), true); + vm.stopPrank(); + } + + function testBatchRevertArrayLength() public { + fund.setProofWl(pwl); + + vm.prank(ADMIN); + vm.expectRevert(); + + fund.investFor(tooManyUsers, options, tokens, amounts); + } + + function testBatchLogFailAmount() public { + fund.setProofWl(pwl); + + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.startPrank(ADMIN); + + assertEq(fund.commit(users, options, tokens, amounts), true); + + vm.mockCall(PUT_MANAGER, abi.encodeWithSelector(INVEST_SELECTOR), abi.encode(1)); + // we'll see bob's fail and alice's succeed + assertEq(fund.investFor(users, options, tokens, wrongAmounts), true); + vm.stopPrank(); + } + + function testBatchLogFailCommittment() public { + fund.setProofWl(pwl); + + // commits + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.startPrank(ADMIN); + + assertEq(fund.commit(users, options, tokens, amounts), true); + + // remit bob such that his commit bal check will fail + assertEq(fund.remit(BOB, OPT_ONE, F_TOKEN_1, 50), true); + + vm.mockCall(PUT_MANAGER, abi.encodeWithSelector(INVEST_SELECTOR), abi.encode(1)); + // we'll see bob's fail and alice's succeed + assertEq(fund.investFor(users, options, tokens, amounts), true); + vm.stopPrank(); + } + + function testBatchInvestForWithOverride() public { + fund.setProofWl(pwl); + fund.toggleCommitBalanceOverride(); + + // commits + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.startPrank(ADMIN); + + assertEq(fund.commit(users, options, tokens, amounts), true); + + // remit bob such that his commit bal check would fail, but is overridden + assertEq(fund.remit(BOB, OPT_ONE, F_TOKEN_1, 50), true); + + vm.mockCall(PUT_MANAGER, abi.encodeWithSelector(INVEST_SELECTOR), abi.encode(1)); + // we'll see both succeed + assertEq(fund.investFor(users, options, tokens, amounts), true); + vm.stopPrank(); + } + + function testInvestForBatch() public { + fund.setProofWl(pwl); + + // need commits.. + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.startPrank(ADMIN); + + assertEq(fund.commit(users, options, tokens, amounts), true); + + vm.mockCall(PUT_MANAGER, abi.encodeWithSelector(INVEST_SELECTOR), abi.encode(1)); + // investFor now callable.. + assertEq(fund.investFor(users, options, tokens, amounts), true); + vm.stopPrank(); + } +} diff --git a/ethereum/test/operable/OPT.sol b/ethereum/test/operable/OPT.sol new file mode 100644 index 0000000..186bd5a --- /dev/null +++ b/ethereum/test/operable/OPT.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.34; + +import {Ownable} from "solady/auth/Ownable.sol"; +import {State, Status} from "shared/operable/Types.sol"; +import {Operable} from "shared/operable/Operable.sol"; + +/// @notice a test dummy for validating the operable lib +contract OPT is Ownable, Operable { + // use as extended functionality for status method override + uint8 public otherStatus; + uint8 public foo; + uint8 public bar; + + // some example operation levels that can be paused + uint32 public constant FOO_LEVEL = 2; + uint32 public constant BAR_LEVEL = 4; + + constructor() { + _initializeOwner(msg.sender); + } + + function setOtherStatus(uint8 n) external returns (bool) { + otherStatus = n; + return true; + } + + // extend the status method to check other status as well.. + function status() public view override returns (Status memory) { + Status memory stat = super.status(); + // only set a state if we have not already as ours take precedence + // let's say target other thing has {0,1,2,3,4} with us mapping them to our own values + if (stat.state == State.Active && otherStatus != 1) { + // we'll say their 0 and 2 are equivalent to paused + if (otherStatus < 3) { + stat.state = State.Paused; + } else { + // and 3,4 mean stopped to us + stat.state = State.Stopped; + } + } + + return stat; + } + + function pause(uint32 level) public override onlyOwner returns (bool) { + return super.pause(level); + } + + function stop() public override onlyOwner returns (bool) { + return super.stop(); + } + + function setFoo(uint8 newFoo) external active(FOO_LEVEL) returns (bool) { + foo = newFoo; + return true; + } + + function setBar(uint8 newBar) external active(BAR_LEVEL) returns (bool) { + bar = newBar; + return true; + } +} diff --git a/ethereum/test/operable/Operable.t.sol b/ethereum/test/operable/Operable.t.sol new file mode 100644 index 0000000..1bed557 --- /dev/null +++ b/ethereum/test/operable/Operable.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {State, Status} from "shared/operable/Types.sol"; +import {OPT} from "./OPT.sol"; + +contract OPTest is Test { + OPT public op; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + + function setUp() public { + op = new OPT(); + // the fictional other status is active at "1" + assert(op.setOtherStatus(1)); + } + + function testActiveStatus() public { + Status memory stat = op.status(); + assertEq(uint8(stat.state), 0); + assertEq(uint32(stat.flags), 0); + } + + function testPauseRevertNotOwner() public { + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + vm.prank(SOMEONE); + op.pause(2); + } + + function testPause() public { + assertEq(op.paused(), 0); + assert(op.pause(op.FOO_LEVEL())); + assertEq(op.paused(), op.FOO_LEVEL()); + } + + function testStopRevertNotOwner() public { + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + vm.prank(SOMEONE); + op.stop(); + } + + function testStop() public { + assertEq(op.stopped(), false); + assert(op.stop()); + assert(op.stopped()); + } + + function testSetFoo() public { + // when not stopped or paused foo can be set + assertEq(op.foo(), 0); + assert(op.setFoo(42)); + assertEq(op.foo(), 42); + + // pause at foo level + assert(op.pause(op.FOO_LEVEL())); + + // status reports correctly + Status memory stat = op.status(); + assertEq(uint8(stat.state), 1); + assertEq(uint32(stat.flags), op.FOO_LEVEL()); + + // does not allow when paused + vm.expectRevert(abi.encodeWithSignature("IsPaused(uint32)", op.FOO_LEVEL())); + op.setFoo(99); + assertEq(op.foo(), 42); + + // foo level pause does not pause bar level operation + assertEq(op.bar(), 0); + assert(op.setBar(67)); + assertEq(op.bar(), 67); + + // can be unpaused + assert(op.pause(0)); + // status is correct + stat = op.status(); + assertEq(uint8(stat.state), 0); + assertEq(uint32(stat.flags), 0); + + // can now be set again + assert(op.setFoo(99)); + assertEq(op.foo(), 99); + + // cannot be set when stopped + assert(op.stop()); + vm.expectRevert(abi.encodeWithSignature("IsStopped()")); + op.setFoo(13); + assertEq(op.foo(), 99); + + // status updates + stat = op.status(); + assertEq(uint8(stat.state), 2); + assertEq(uint32(stat.flags), 1); + } + + function testSetBar() public { + // when not stopped or paused foo can be set + assertEq(op.bar(), 0); + assert(op.setBar(42)); + assertEq(op.bar(), 42); + + // pause at bar level + assert(op.pause(op.BAR_LEVEL())); + + // does not allow when paused + vm.expectRevert(abi.encodeWithSignature("IsPaused(uint32)", op.BAR_LEVEL())); + op.setBar(99); + assertEq(op.bar(), 42); + + // bar level pause does not pause foo level operation + assertEq(op.foo(), 0); + assert(op.setFoo(67)); + assertEq(op.foo(), 67); + + // can be unpaused + assert(op.pause(0)); + // can now be set again + assert(op.setBar(99)); + assertEq(op.bar(), 99); + + // cannot be set when stopped + assert(op.stop()); + vm.expectRevert(abi.encodeWithSignature("IsStopped()")); + op.setBar(13); + assertEq(op.bar(), 99); + } + + function testPauseAllViaBitmask() public { + uint32 both = op.FOO_LEVEL() + op.BAR_LEVEL(); + assert(op.pause(both)); + assertEq(op.paused(), both); + + // since you are calling setFoo it will throw with FOO_LEVEL + vm.expectRevert(abi.encodeWithSignature("IsPaused(uint32)", op.FOO_LEVEL())); + op.setFoo(37); + + // now BAR_LEVEL will be thrown + vm.expectRevert(abi.encodeWithSignature("IsPaused(uint32)", op.BAR_LEVEL())); + op.setBar(37); + + assertEq(op.foo(), 0); + assertEq(op.bar(), 0); + } + + function testStatusWithOther() public { + // 0 mapped to paused + assert(op.setOtherStatus(0)); + Status memory stat = op.status(); + assertEq(uint8(stat.state), 1); + // no flags means its external + assertEq(uint32(stat.flags), 0); + + // 3 and above would be stopped + assert(op.setOtherStatus(4)); + stat = op.status(); + assertEq(uint8(stat.state), 2); + assertEq(uint32(stat.flags), 0); + + // "internal" settings will override + // NOTE: we'd likely "match" them but this is strictly informative + // and who knows ... + assert(op.pause(op.FOO_LEVEL())); + stat = op.status(); + assertEq(uint8(stat.state), 1); + assertEq(uint32(stat.flags), op.FOO_LEVEL()); + } +} diff --git a/ethereum/test/registry/Register.t.sol b/ethereum/test/registry/Register.t.sol new file mode 100644 index 0000000..1fadfd7 --- /dev/null +++ b/ethereum/test/registry/Register.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {Registry} from "registry/Registry.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; + +contract Registration is Test { + Registry public reg; + TokenSaleFund public fund; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + address public constant WHATEVER = 0x4040404040404040404040404040404040404040; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + function setUp() public { + // registry expects the registered target to be an actual contract + fund = new TokenSaleFund(100, SALE_ID); + reg = new Registry(); + // must be assigned register level + reg.grantRoles(ADMIN, reg.REGISTER_LEVEL() + reg.DEREGISTER_LEVEL()); + } + + function testRevertNotRegistrar() public { + vm.prank(SOMEONE); + vm.expectRevert(); + reg.register(SALE_ID, 0, WHATEVER); + } + + function testRevertNotContract() public { + vm.prank(ADMIN); + vm.expectRevert(); + reg.register(SALE_ID, 0, WHATEVER); + } + + function testRegister() public { + vm.startPrank(ADMIN); + assertEq(reg.register(SALE_ID, fund.KIND(), address(fund)), true); + assertEq(reg.registered(SALE_ID, fund.KIND()), address(fund)); + vm.stopPrank(); + } + + function testRevertAlreadyRegistered() public { + // so that the expect revert doesn't trigger on KIND() + uint256 kind = fund.KIND(); + + vm.startPrank(ADMIN); + assertEq(reg.register(SALE_ID, kind, address(fund)), true); + assertEq(reg.registered(SALE_ID, kind), address(fund)); + + vm.expectRevert(); + reg.register(SALE_ID, kind, address(fund)); + vm.stopPrank(); + } + + function testRevertNotDeregistrar() public { + vm.expectRevert(); + reg.deregister(SALE_ID, 0); + } + + function testDeregister() public { + vm.startPrank(ADMIN); + assertEq(reg.register(SALE_ID, fund.KIND(), address(fund)), true); + assertEq(reg.registered(SALE_ID, fund.KIND()), address(fund)); + assertEq(reg.deregister(SALE_ID, fund.KIND()), true); + assertEq(reg.registered(SALE_ID, fund.KIND()), address(0)); + } + + function testReregister() public { + vm.startPrank(ADMIN); + assertEq(reg.register(SALE_ID, fund.KIND(), address(fund)), true); + assertEq(reg.registered(SALE_ID, fund.KIND()), address(fund)); + assertEq(reg.deregister(SALE_ID, fund.KIND()), true); + assertEq(reg.registered(SALE_ID, fund.KIND()), address(0)); + assertEq(reg.register(SALE_ID, fund.KIND(), address(fund)), true); + } +} diff --git a/ethereum/test/sale/Approve.t.sol b/ethereum/test/sale/Approve.t.sol new file mode 100644 index 0000000..ed01da0 --- /dev/null +++ b/ethereum/test/sale/Approve.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; + +contract TokenSaleApprove is Test { + TokenSaleFund public fund; + address public constant F_TOKEN = 0x4040404040404040404040404040404040404040; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + + bytes4 public constant APPROVE_SELECTOR = bytes4(keccak256("approve(address,uint256)")); + + function setUp() public { + fund = new TokenSaleFund(100, keccak256("abc-123")); + // can both remit and commit, but still cannot transfer as not owner + fund.grantRoles(ADMIN, fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + } + + function testRevertWhenNotOwner() public { + vm.prank(ADMIN); + vm.expectRevert(); + fund.approve(SOMEONE, F_TOKEN, 1000); + } + + function testRevertApprove() public { + vm.mockCall(F_TOKEN, abi.encodeWithSelector(APPROVE_SELECTOR), abi.encode(false)); + vm.expectRevert(); + fund.approve(SOMEONE, F_TOKEN, 1000); + } + + function testApprove() public { + vm.mockCall(F_TOKEN, abi.encodeWithSelector(APPROVE_SELECTOR), abi.encode(true)); + + assertEq(fund.approve(SOMEONE, F_TOKEN, 1000), true); + } +} diff --git a/ethereum/test/sale/Commit.t.sol b/ethereum/test/sale/Commit.t.sol new file mode 100644 index 0000000..1e0ceea --- /dev/null +++ b/ethereum/test/sale/Commit.t.sol @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; +import {SaleTotal as Total} from "sale/Types.sol"; + +contract TokenSaleCommit is Test { + TokenSaleFund public fund; + address public constant F_TOKEN_1 = 0x4040404040404040404040404040404040404040; + address public constant F_TOKEN_2 = 0x5050505050505050505050505050505050505050; + address public constant ALICE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + address public constant ADMIN = 0x8080808080808080808080808080808080808080; + bytes32 public constant SALE_ID = keccak256("abc-123"); + bytes32 public constant OPT_ONE = keccak256("opt-12345"); + bytes32 public constant OPT_TWO = keccak256("opt-67890"); + + bytes4 public constant TRANSFER_FROM_SELECTOR = bytes4(keccak256("transferFrom(address,address,uint256)")); + + address[] public tooManyUsers = [BOB, ALICE, F_TOKEN_1]; + address[] public users = [BOB, ALICE]; + address[] public zeroAndAlice = [address(0), ALICE]; + address[] public contractAndAlice = new address[](2); + bytes32[] public options = [OPT_ONE, OPT_TWO]; + address[] public tokens = [F_TOKEN_1, F_TOKEN_2]; + uint256[] public amounts = [5, 10]; + uint256[] public wrongAmounts = [4, 10]; + + function setUp() public { + fund = new TokenSaleFund(5, SALE_ID); + fund.grantRoles(ADMIN, fund.COMMIT_LEVEL()); + + contractAndAlice[0] = address(fund); + contractAndAlice[1] = ALICE; + } + + // *********** commit call and bookkeeping ******************************** + + function testRevertWhenStopped() public { + fund.stop(fund.COMMIT_LEVEL()); + vm.expectRevert(); + + fund.commit(ALICE, OPT_ONE, F_TOKEN_2, 10); + + assertEq(zero(ALICE, OPT_ONE, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testBatchRevertWhenStopped() public { + // can also use the LEVEL_SUM to indicate both commit/remit + fund.stop(fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + vm.expectRevert(); + + fund.commit(users, options, tokens, amounts); + + assertEq(zero(BOB, OPT_ONE, F_TOKEN_1), true); + assertEq(zero(ALICE, OPT_TWO, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testRevertWhenNotCommitter() public { + vm.prank(BOB); + + vm.expectRevert(); + fund.commit(ALICE, OPT_ONE, F_TOKEN_1, 10); + + assertEq(zero(ALICE, OPT_ONE, F_TOKEN_1), true); + assertEq(zero(BOB, OPT_ONE, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testBatchRevertWhenNotCommitter() public { + vm.prank(BOB); + + vm.expectRevert(); + fund.commit(users, options, tokens, amounts); + + assertEq(zero(BOB, OPT_ONE, F_TOKEN_1), true); + assertEq(zero(ALICE, OPT_TWO, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testRevertZeroAddress() public { + vm.prank(ADMIN); + vm.expectRevert(); + fund.commit(address(0), OPT_TWO, F_TOKEN_1, 2); + } + + function testRevertContractAddress() public { + vm.prank(ADMIN); + vm.expectRevert(); + fund.commit(address(fund), OPT_TWO, F_TOKEN_1, 2); + } + + function testRevertMinCommit() public { + vm.prank(ADMIN); + vm.expectRevert(); + fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 2); + + assertEq(zero(ALICE, OPT_TWO, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_1), true); + } + + function testLogFailZeroAddress() public { + vm.prank(ADMIN); + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + // we'll see zero fail and bob's succeed.. + assertEq(fund.commit(zeroAndAlice, options, tokens, amounts), true); + assertEq(zero(address(0), OPT_ONE, F_TOKEN_1), true); + + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + + // contract totals updated + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + } + + function testLogFailContractAddress() public { + vm.prank(ADMIN); + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + // we'll see zero fail and bob's succeed.. + assertEq(fund.commit(contractAndAlice, options, tokens, amounts), true); + assertEq(zero(address(fund), OPT_ONE, F_TOKEN_1), true); + + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + + // contract totals updated + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + } + + function testLogFailMinCommit() public { + vm.prank(ADMIN); + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + // we'll see bob's fail and alice's succeed.. + assertEq(fund.commit(users, options, tokens, wrongAmounts), true); + assertEq(zero(BOB, OPT_ONE, F_TOKEN_1), true); + + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + + // contract totals updated + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + } + + function testRevertTransferFrom() public { + vm.prank(ADMIN); + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(false)); + + vm.expectRevert(); + fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 10); + + assertEq(zero(ALICE, OPT_TWO, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_1), true); + } + + function testLogFailTransferFrom() public { + vm.prank(ADMIN); + // specifically using revert to assure trySafeTransferFrom catches it properly + vm.mockCallRevert(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(false)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + // we'll see bob's fail and alice's succeed + assertEq(fund.commit(users, options, tokens, amounts), true); + assertEq(zero(BOB, OPT_ONE, F_TOKEN_1), true); + + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + + // contract totals updated + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + } + + function testCommitOnceAlice() public { + // the contract could have remit stopped, but commit still on + assertEq(fund.stop(fund.REMIT_LEVEL()), true); + + vm.prank(ADMIN); + // will pass the invariants + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 10), true); + + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + + // the public getter for commit balance is also available + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_1), 10); + // this works for the contract globally as well (per token) + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_1), 10); + + // incorrect paths to the commitBalance are still zero + assertEq(fund.commitBalance(ALICE, OPT_ONE, F_TOKEN_1), 0); + assertEq(fund.commitBalance(ALICE, OPT_ONE, F_TOKEN_2), 0); + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_2), 0); + } + + function testCommitOnceAliceAndBob() public { + vm.startPrank(ADMIN); + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + assertEq(fund.commit(BOB, OPT_ONE, F_TOKEN_1, 5), true); + Total memory t = fund.totals(BOB, OPT_ONE, F_TOKEN_1); + + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(fund.commitBalance(BOB, OPT_ONE, F_TOKEN_1), 5); + + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + assertEq(fund.commit(ALICE, OPT_ONE, F_TOKEN_2, 10), true); + t = fund.totals(ALICE, OPT_ONE, F_TOKEN_2); + + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + assertEq(fund.commitBalance(ALICE, OPT_ONE, F_TOKEN_2), 10); + vm.stopPrank(); + + // global totals + t = fund.totals(address(fund), SALE_ID, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + } + + function testBatchRevertArrayLength() public { + vm.prank(ADMIN); + vm.expectRevert(); + + fund.commit(tooManyUsers, options, tokens, amounts); + } + + function testBatchCommitAliceAndBob() public { + vm.prank(ADMIN); + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + assertEq(fund.commit(users, options, tokens, amounts), true); + Total memory t = fund.totals(BOB, OPT_ONE, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + + t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + + // global totals + t = fund.totals(address(fund), SALE_ID, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + } + + // same token + function testCommittedTwiceAlice() public { + vm.startPrank(ADMIN); + // first commit + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 5), true); + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_1), 5); + + // second commit + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 10), true); + t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + + assertEq(t.commitCount, 2); + assertEq(t.commitSum, 15); + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_1), 15); + vm.stopPrank(); + + // global totals + t = fund.totals(address(fund), SALE_ID, F_TOKEN_1); + assertEq(t.commitCount, 2); + assertEq(t.commitSum, 15); + } + + // ************************* Utility ************************** + + function zero(address user, bytes32 option, address token) internal view returns (bool) { + Total memory t = fund.totals(user, option, token); + + return t.commitCount == 0 && t.commitSum == 0; + } +} diff --git a/ethereum/test/sale/DefaultDistState.t.sol b/ethereum/test/sale/DefaultDistState.t.sol new file mode 100644 index 0000000..73c7db3 --- /dev/null +++ b/ethereum/test/sale/DefaultDistState.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSaleDist} from "sale/TokenSaleDist.sol"; + +contract DistDefault is Test { + TestToken public token; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + function setUp() public { + token = new TestToken("TestToken", "TEST"); + } + + function testRevertZeroDistTokenAddr() public { + vm.expectRevert(); + new TokenSaleDist(address(0), SALE_ID); + } + + function testRevertNotContract() public { + vm.expectRevert(); + new TokenSaleDist(SOMEONE, SALE_ID); + } + + function testDistToken() public { + TokenSaleDist dist = new TokenSaleDist(address(token), SALE_ID); + assertEq(dist.distToken(), address(token)); + } + + function testRevertStopWhenNotOwner() public { + TokenSaleDist dist = new TokenSaleDist(address(token), SALE_ID); + + vm.prank(BOB); + vm.expectRevert(); + + dist.stop(2); + } +} diff --git a/ethereum/test/sale/DefaultFundState.t.sol b/ethereum/test/sale/DefaultFundState.t.sol new file mode 100644 index 0000000..04648f8 --- /dev/null +++ b/ethereum/test/sale/DefaultFundState.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; + +contract TokenSaleFundDefault is Test { + TokenSaleFund public fund; + address public constant ALICE = 0x5050505050505050505050505050505050505050; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + function setUp() public { + fund = new TokenSaleFund(100, SALE_ID); + } + + // ********* properties *************************************************** + + function testIsOwned() public view { + // all forge test contracts are deployed from 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 + assertEq(fund.owner(), 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496); + } + + function testIsNotStopped() public view { + assertEq(fund.stopped(), 0); + } + + function testSaleId() public view { + assertEq(fund.id(), SALE_ID); + } + + function testSaleMinCommit() public view { + assertEq(fund.minCommit(), 100); + } + + // ******** default state changes ***************************************** + + function testAssignRole() public { + fund.grantRoles(ALICE, 1); + + assertEq(fund.rolesOf(ALICE), 1); + + // numbers above the granted role are false + assertEq(fund.hasAnyRole(ALICE, 2), false); + + fund.grantRoles(ALICE, 3); + + // roles of will return the highest + assertEq(fund.rolesOf(ALICE), 3); + + // any role <= the highest is now true + assertEq(fund.hasAnyRole(ALICE, 1), true); + assertEq(fund.hasAnyRole(ALICE, 2), true); + assertEq(fund.hasAnyRole(ALICE, 3), true); + assertEq(fund.hasAnyRole(ALICE, 4), false); + } + + function testOwnerIsNotRole() public view { + assertEq(fund.hasAnyRole(0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 4), false); + } + + function testRevertStopWhenNotOwner() public { + vm.prank(ALICE); + vm.expectRevert(); + + fund.stop(2); + } + + function testStop() public { + assertEq(fund.stopped(), 0); + fund.stop(2); + assertEq(fund.stopped(), 2); + fund.stop(4); + assertEq(fund.stopped(), 4); + } +} diff --git a/ethereum/test/sale/DistTransfer.t.sol b/ethereum/test/sale/DistTransfer.t.sol new file mode 100644 index 0000000..934ddf7 --- /dev/null +++ b/ethereum/test/sale/DistTransfer.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSaleDist} from "sale/TokenSaleDist.sol"; + +contract DistTransfer is Test { + TestToken public token; + TokenSaleDist public dist; + address public constant A_TOKEN = 0x4040404040404040404040404040404040404040; + address public constant SOMEPLACE = 0x6060606060606060606060606060606060606060; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + + bytes4 public constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256)")); + + function setUp() public { + token = new TestToken("TestToken", "TEST"); + dist = new TokenSaleDist(address(token), keccak256("abc-123")); + dist.grantRoles(ADMIN, dist.DISTRIBUTE_LEVEL()); + } + + function testRevertWhenNotOwner() public { + vm.prank(ADMIN); + vm.expectRevert(); + // assert doesn't matter here... + assertEq(dist.transfer(SOMEPLACE, 10), false); + } + + function testRevertWhenNotOwnerExt() public { + vm.prank(ADMIN); + vm.expectRevert(); + assertEq(dist.transfer(SOMEPLACE, A_TOKEN, 10), false); + } + + function testRevertTransfer() public { + vm.mockCall(address(token), abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(false)); + vm.expectRevert(); + assertEq(dist.transfer(SOMEPLACE, 10), false); + } + + function testRevertTransferExt() public { + vm.mockCall(A_TOKEN, abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(false)); + vm.expectRevert(); + dist.transfer(SOMEPLACE, A_TOKEN, 10); + } + + function testTransfer() public { + // get a balance + token.mint(address(dist), 50); + + assertEq(dist.distributionTokenBalance(), 50); + assertEq(dist.transfer(SOMEPLACE, 50), true); + // should have a zero bal + assertEq(dist.distributionTokenBalance(), 0); + } + + function testTransferExt() public { + vm.mockCall(A_TOKEN, abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(true)); + assertEq(dist.transfer(SOMEPLACE, A_TOKEN, 50), true); + } +} diff --git a/ethereum/test/sale/Distribute.t.sol b/ethereum/test/sale/Distribute.t.sol new file mode 100644 index 0000000..7f1ed6a --- /dev/null +++ b/ethereum/test/sale/Distribute.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSaleDist} from "sale/TokenSaleDist.sol"; +import {DistTotal as Total} from "sale/Types.sol"; + +contract TokenSaleRemit is Test { + TestToken public token; + TokenSaleDist public dist; + address public constant D_TOKEN = 0x4040404040404040404040404040404040404040; + address public constant ALICE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + address public constant DISTRIBUTOR = 0x8080808080808080808080808080808080808080; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + bytes4 public constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256)")); + + address[] public users = [BOB, ALICE]; + address[] public wrongUsers = [BOB, ALICE, DISTRIBUTOR]; + uint256[] public amounts = [5, 10]; + uint256[] public wrongAmounts = [5, 0]; + + function setUp() public { + token = new TestToken("TestToken", "TEST"); + dist = new TokenSaleDist(address(token), SALE_ID); + // can distribute + dist.grantRoles(DISTRIBUTOR, dist.DISTRIBUTE_LEVEL()); + } + + // *********** distribute call and bookkeeping ******************************** + + function testRevertWhenStopped() public { + dist.stop(dist.DISTRIBUTE_LEVEL()); + vm.expectRevert(); + + dist.distribute(ALICE, 10); + + assertEq(zero(ALICE), true); + assertEq(zero(address(dist)), true); + } + + function testRevertBatchWhenStopped() public { + dist.stop(dist.DISTRIBUTE_LEVEL()); + vm.expectRevert(); + + dist.distribute(users, amounts); + + assertEq(zero(ALICE), true); + assertEq(zero(BOB), true); + assertEq(zero(address(dist)), true); + } + + function testRevertBatchListLength() public { + vm.prank(DISTRIBUTOR); + vm.expectRevert(); + dist.distribute(wrongUsers, amounts); + } + + function testRevertWhenNotDistributor() public { + // does not have distribute level perms + vm.prank(ALICE); + vm.expectRevert(); + dist.distribute(ALICE, 10); + + // no dist recorded + assertEq(zero(ALICE), true); + assertEq(zero(BOB), true); + assertEq(zero(address(dist)), true); + } + + function testRevertZeroAddress() public { + vm.prank(DISTRIBUTOR); + vm.expectRevert(); + dist.distribute(address(0), 10); + } + + function testRevertContractAddress() public { + vm.prank(DISTRIBUTOR); + vm.expectRevert(); + dist.distribute(address(dist), 10); + } + + function testReverMinDist() public { + vm.prank(DISTRIBUTOR); + vm.expectRevert(); + dist.distribute(ALICE, 0); + + assertEq(zero(ALICE), true); + assertEq(zero(address(dist)), true); + } + + // we hove no balance of the dist token.. + function testRevertNoBalance() public { + vm.prank(DISTRIBUTOR); + vm.expectRevert(); + dist.distribute(ALICE, 10); + + assertEq(zero(ALICE), true); + assertEq(zero(address(dist)), true); + } + + function testRevertBatchMinDist() public { + // get balance of the dist token + token.mint(address(dist), 20); + vm.prank(DISTRIBUTOR); + vm.expectRevert(); + // BOB will succeed, ALICE will fail with min + dist.distribute(users, wrongAmounts); + + // no bookkeeping as no partial pass is accepted + assertEq(zero(ALICE), true); + assertEq(zero(BOB), true); + assertEq(zero(address(dist)), true); + } + + function testDistribute() public { + token.mint(address(dist), 20); + // public getter now reports correctly + assertEq(dist.distributionTokenBalance(), 20); + + vm.startPrank(DISTRIBUTOR); + dist.distribute(ALICE, 10); + + // alice has bookkeeping present + Total memory t = dist.totals(ALICE); + assertEq(t.distCount, 1); + assertEq(t.distSum, 10); + + // bob has bookkeeping present + dist.distribute(BOB, 5); + t = dist.totals(BOB); + assertEq(t.distCount, 1); + assertEq(t.distSum, 5); + + // contract has global bookkeeping + t = dist.totals(address(dist)); + assertEq(t.distCount, 2); + assertEq(t.distSum, 15); + + // global balance updated + assertEq(dist.distributionTokenBalance(), 5); + vm.stopPrank(); + } + + function testBatchDistribute() public { + token.mint(address(dist), 20); + + vm.prank(DISTRIBUTOR); + dist.distribute(users, amounts); + + // alice + Total memory t = dist.totals(ALICE); + assertEq(t.distCount, 1); + assertEq(t.distSum, 10); + + // bob + t = dist.totals(BOB); + assertEq(t.distCount, 1); + assertEq(t.distSum, 5); + + // contract + t = dist.totals(address(dist)); + assertEq(t.distCount, 2); + assertEq(t.distSum, 15); + + // global balance updated + assertEq(dist.distributionTokenBalance(), 5); + } + + // ************************* Utility ************************** + + function zero(address user) internal view returns (bool) { + Total memory t = dist.totals(user); + + return t.distCount == 0 && t.distSum == 0; + } +} diff --git a/ethereum/test/sale/FundTransfer.t.sol b/ethereum/test/sale/FundTransfer.t.sol new file mode 100644 index 0000000..440193d --- /dev/null +++ b/ethereum/test/sale/FundTransfer.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; + +contract FundTransfer is Test { + TokenSaleFund public fund; + address public constant F_TOKEN = 0x4040404040404040404040404040404040404040; + address public constant SOMEPLACE = 0x6060606060606060606060606060606060606060; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + + bytes4 public constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256)")); + + function setUp() public { + fund = new TokenSaleFund(100, keccak256("abc-123")); + // can both remit and commit, but still cannot transfer as not owner + fund.grantRoles(ADMIN, fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + } + + function testRevertWhenNotOwner() public { + vm.prank(ADMIN); + vm.expectRevert(); + fund.transfer(SOMEPLACE, F_TOKEN, 10); + } + + function testRevertTransfer() public { + vm.mockCall(F_TOKEN, abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(false)); + vm.expectRevert(); + fund.transfer(SOMEPLACE, F_TOKEN, 10); + } + + function testTransfer() public { + vm.mockCall(F_TOKEN, abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(true)); + + assertEq(fund.transfer(SOMEPLACE, F_TOKEN, 50), true); + } +} diff --git a/ethereum/test/sale/Remit.t.sol b/ethereum/test/sale/Remit.t.sol new file mode 100644 index 0000000..bb3e3e7 --- /dev/null +++ b/ethereum/test/sale/Remit.t.sol @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; +import {SaleTotal as Total} from "sale/Types.sol"; + +contract TokenSaleRemit is Test { + TokenSaleFund public fund; + address public constant F_TOKEN_1 = 0x4040404040404040404040404040404040404040; + address public constant F_TOKEN_2 = 0x5050505050505050505050505050505050505050; + address public constant ALICE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + address public constant REMITTER = 0x8080808080808080808080808080808080808080; + address public constant COMMITTER = 0x9090909090909090909090909090909090909090; + address public constant ADMIN = 0x1010101010101010101010101010101010101010; + bytes32 public constant SALE_ID = keccak256("abc-123"); + bytes32 public constant OPT_ONE = keccak256("opt-12345"); + bytes32 public constant OPT_TWO = keccak256("opt-67890"); + + bytes4 public constant TRANSFER_SELECTOR = bytes4(keccak256("transfer(address,uint256)")); + bytes4 public constant TRANSFER_FROM_SELECTOR = bytes4(keccak256("transferFrom(address,address,uint256)")); + + address[] public users = [BOB, ALICE]; + address[] public zeroAndAlice = [address(0), ALICE]; + address[] public contractAndAlice = new address[](2); + uint256[] public amounts = [5, 10]; + uint256[] public wrongAmounts = [5, 0]; + + function setUp() public { + fund = new TokenSaleFund(5, SALE_ID); + // can only commit + fund.grantRoles(COMMITTER, fund.COMMIT_LEVEL()); + // can only remit + fund.grantRoles(REMITTER, fund.REMIT_LEVEL()); + // can both remit and commit + fund.grantRoles(ADMIN, fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + + contractAndAlice[0] = address(fund); + contractAndAlice[1] = ALICE; + } + + // *********** refund call and bookkeeping ******************************** + + function testRevertWhenStopped() public { + fund.stop(fund.REMIT_LEVEL()); + vm.expectRevert(); + + fund.remit(ALICE, OPT_ONE, F_TOKEN_2, 10); + + assertEq(zero(ALICE, OPT_ONE, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testRevertBatchWhenStopped() public { + // may also use the sum of each level, stopping both + fund.stop(fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + vm.expectRevert(); + + fund.remit(users, OPT_ONE, F_TOKEN_2, amounts); + + assertEq(zero(ALICE, OPT_ONE, F_TOKEN_2), true); + assertEq(zero(BOB, OPT_ONE, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testRevertWhenNotRemitter() public { + // does not have remit level perms + vm.prank(COMMITTER); + vm.expectRevert(); + fund.remit(ALICE, OPT_ONE, F_TOKEN_1, 10); + + // no remits recorded + assertEq(zero(ALICE, OPT_ONE, F_TOKEN_1), true); + assertEq(zero(BOB, OPT_ONE, F_TOKEN_1), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_1), true); + } + + function testRevertZeroAddress() public { + vm.prank(REMITTER); + vm.expectRevert(); + fund.remit(address(0), OPT_TWO, F_TOKEN_2, 10); + } + + function testRevertContractAddress() public { + vm.prank(REMITTER); + vm.expectRevert(); + fund.remit(address(fund), OPT_TWO, F_TOKEN_2, 10); + } + + function testReverMinRemit() public { + vm.prank(REMITTER); + vm.expectRevert(); + fund.remit(ALICE, OPT_TWO, F_TOKEN_2, 0); + + assertEq(zero(ALICE, OPT_TWO, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testRevertNoCommitment() public { + vm.prank(REMITTER); + vm.expectRevert(); + fund.remit(ALICE, OPT_TWO, F_TOKEN_2, 10); + + assertEq(zero(ALICE, OPT_TWO, F_TOKEN_2), true); + assertEq(zero(address(fund), SALE_ID, F_TOKEN_2), true); + } + + function testRevertPostCommitTransfer() public { + vm.startPrank(ADMIN); + // setup alice's commit + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_2, 10), true); + + // reverts here for some reason.. + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(false)); + vm.expectRevert(); + fund.remit(ALICE, OPT_TWO, F_TOKEN_2, 10); + + // alice's bookkeeping still in place + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + // no remits recorded + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + + vm.stopPrank(); + + // contract commit balance would reflect alice's commit + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_2), 10); + } + + function testRemit() public { + vm.startPrank(ADMIN); + // setup bob's commit + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(BOB, OPT_ONE, F_TOKEN_2, 10), true); + + // bookkeeping present + Total memory t = fund.totals(BOB, OPT_ONE, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + assertEq(fund.commitBalance(BOB, OPT_ONE, F_TOKEN_2), 10); + // no remits recorded yet + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + + vm.stopPrank(); + + // remit will function if stop level is commit only + assertEq(fund.stop(fund.COMMIT_LEVEL()), true); + + vm.startPrank(ADMIN); + // return partial funds + assertEq(fund.remit(BOB, OPT_ONE, F_TOKEN_2, 5), true); + + // does not alter commit total + t = fund.totals(BOB, OPT_ONE, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + // remits present + assertEq(t.remitCount, 1); + assertEq(t.remitSum, 5); + // properly reflected in commit balance + assertEq(fund.commitBalance(BOB, OPT_ONE, F_TOKEN_2), 5); + vm.stopPrank(); + + // contract globals + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + assertEq(t.remitCount, 1); + assertEq(t.remitSum, 5); + } + + function testBatchRevertPostCommitZeroAddress() public { + vm.startPrank(ADMIN); + // setup bob's commit + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 5), true); + + // bookkeeping present + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + // no remits recorded + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + + // reverts on zero address + vm.expectRevert(); + fund.remit(zeroAndAlice, OPT_TWO, F_TOKEN_1, amounts); + + t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + // remits not recorded + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + // commits unchanged + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_1), 5); + vm.stopPrank(); + + // contract globals + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_1), 5); + } + + function testBatchRevertPostCommitContractAddress() public { + vm.startPrank(ADMIN); + // setup bob's commit + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_1, 5), true); + + // bookkeeping present + Total memory t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + // no remits recorded + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + + // reverts on zero address + vm.expectRevert(); + fund.remit(contractAndAlice, OPT_TWO, F_TOKEN_1, amounts); + + t = fund.totals(ALICE, OPT_TWO, F_TOKEN_1); + // remits not recorded for alice + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + // commits unchanged + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_1), 5); + vm.stopPrank(); + + // contract globals + t = fund.totals(address(fund), SALE_ID, F_TOKEN_1); + assertEq(t.commitSum, 5); + assertEq(t.remitCount, 0); + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_1), 5); + } + + function testBatchRevertPostCommitMinRemit() public { + vm.startPrank(ADMIN); + // setup bob's commit + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(BOB, OPT_TWO, F_TOKEN_1, 5), true); + + // bookkeeping present + Total memory t = fund.totals(BOB, OPT_TWO, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + // no remits recorded yet + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + + // reverts for min amount + vm.expectRevert(); + fund.remit(users, OPT_TWO, F_TOKEN_1, wrongAmounts); + + t = fund.totals(BOB, OPT_TWO, F_TOKEN_1); + // remits not recorded + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + // commits unchanged + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(fund.commitBalance(BOB, OPT_TWO, F_TOKEN_1), 5); + vm.stopPrank(); + + // contract globals + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_1), 5); + } + + function testBatchRevertPostCommitTransfer() public { + vm.startPrank(ADMIN); + // setup bob's commit + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(BOB, OPT_TWO, F_TOKEN_1, 5), true); + + // bookkeeping present + Total memory t = fund.totals(BOB, OPT_TWO, F_TOKEN_1); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + // no remits recorded yet + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + + // reverts for some reason at the token transfer + vm.mockCall(F_TOKEN_1, abi.encodeWithSelector(TRANSFER_SELECTOR), abi.encode(false)); + vm.expectRevert(); + fund.remit(users, OPT_TWO, F_TOKEN_1, amounts); + + t = fund.totals(BOB, OPT_TWO, F_TOKEN_1); + // remits not recorded + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + // commits unchanged + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(fund.commitBalance(BOB, OPT_TWO, F_TOKEN_1), 5); + vm.stopPrank(); + + // contract globals + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_1), 5); + } + + // NOTE: remit batches must be equivalent option and funding tokens + function testRemitBatch() public { + vm.startPrank(ADMIN); + // setup bob's commit + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(BOB, OPT_TWO, F_TOKEN_2, 5), true); + + // bookkeeping present + Total memory t = fund.totals(BOB, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + // no remits recorded yet + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + assertEq(fund.commitBalance(BOB, OPT_TWO, F_TOKEN_2), 5); + + // setup alice's commit + vm.mockCall(F_TOKEN_2, abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + assertEq(fund.commit(ALICE, OPT_TWO, F_TOKEN_2, 10), true); + + t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + assertEq(t.remitCount, 0); + assertEq(t.remitSum, 0); + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_2), 10); + + // return all funding to both users + assertEq(fund.remit(users, OPT_TWO, F_TOKEN_2, amounts), true); + + // alice + t = fund.totals(ALICE, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 10); + assertEq(t.remitCount, 1); + assertEq(t.remitSum, 10); + // commit balance is zeroed + assertEq(fund.commitBalance(ALICE, OPT_TWO, F_TOKEN_2), 0); + + // bob + t = fund.totals(BOB, OPT_TWO, F_TOKEN_2); + assertEq(t.commitCount, 1); + assertEq(t.commitSum, 5); + assertEq(t.remitCount, 1); + assertEq(t.remitSum, 5); + assertEq(fund.commitBalance(BOB, OPT_TWO, F_TOKEN_2), 0); + vm.stopPrank(); + + // contract globals + t = fund.totals(address(fund), SALE_ID, F_TOKEN_2); + assertEq(t.commitCount, 2); + assertEq(t.commitSum, 15); + assertEq(t.remitCount, 2); + assertEq(t.remitSum, 15); + assertEq(fund.commitBalance(address(fund), SALE_ID, F_TOKEN_2), 0); + } + + // ************************* Utility ************************** + + function zero(address user, bytes32 option, address token) internal view returns (bool) { + Total memory t = fund.totals(user, option, token); + + return t.remitCount == 0 && t.remitSum == 0; + } +} diff --git a/ethereum/test/sale/integration/Funding.t.sol b/ethereum/test/sale/integration/Funding.t.sol new file mode 100644 index 0000000..f4f8d58 --- /dev/null +++ b/ethereum/test/sale/integration/Funding.t.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSaleFund} from "sale/TokenSaleFund.sol"; +import {SaleTotal as Total} from "sale/Types.sol"; + +contract FundingIntegration is Test { + TestToken public token; + TokenSaleFund public fund; + address public constant ALICE = 0xfCe15dD3A9867daf6aA6e5b547eE27554E2E8893; + address public constant BOB = 0x6060606060606060606060606060606060606060; + address public constant ADMIN = 0x7070707070707070707070707070707070707070; + address public constant SOMEBODY = 0x8080808080808080808080808080808080808080; + bytes32 public constant SALE_ID = keccak256("abc-123"); + // a hashed uuid as an example + bytes32 public constant OPT = 0xfb175f72f2dd9e5dae06ce01ca5fb9abce099d7ecb0405332c9a4ea5a74f5718; + address[] public users = [ALICE, BOB]; + + function setUp() public { + token = new TestToken("TestToken", "TEST"); + fund = new TokenSaleFund(10, SALE_ID); + + fund.grantRoles(ADMIN, fund.COMMIT_LEVEL() + fund.REMIT_LEVEL()); + // alice's balance at the funding token + token.mint(ALICE, 100); + } + + function testDefaultState() public view { + address tokenAddr = address(token); + + assertEq(token.name(), "TestToken"); + assertEq(token.symbol(), "TEST"); + assertEq(token.decimals(), 18); + + assertEq(token.balanceOf(ALICE), 100); + assertEq(token.balanceOf(BOB), 0); + // the allowance for fund via alice should be 0 + assertEq(token.allowance(ALICE, address(fund)), 0); + // alice, bob have no committment + Total memory t = fund.totals(ALICE, OPT, tokenAddr); + assertEq(t.commitSum, 0); + t = fund.totals(BOB, OPT, tokenAddr); + assertEq(t.commitSum, 0); + } + + // various paths for alice commits / remits / reverts + function testAlice() public { + address fundAddr = address(fund); + address tokenAddr = address(token); + // make the caller alice + vm.startPrank(ALICE); + assertEq(token.approve(fundAddr, 50), true); + assertEq(token.allowance(ALICE, fundAddr), 50); + vm.stopPrank(); + + vm.startPrank(ADMIN); + // call to commit + assertEq(fund.commit(ALICE, OPT, tokenAddr, 25), true); + Total memory t = fund.totals(ALICE, OPT, tokenAddr); + assertEq(t.commitSum, 25); + assertEq(fund.commitBalance(ALICE, OPT, tokenAddr), 25); + // fund now has tokens + assertEq(token.balanceOf(ALICE), 75); + assertEq(token.balanceOf(fundAddr), 25); + + // alice allowance will now be 25 + + // commits 10 more + assertEq(fund.commit(ALICE, OPT, tokenAddr, 10), true); + t = fund.totals(ALICE, OPT, tokenAddr); + assertEq(t.commitCount, 2); + assertEq(t.commitSum, 35); + assertEq(fund.commitBalance(ALICE, OPT, tokenAddr), 35); + assertEq(token.balanceOf(ALICE), 65); + assertEq(token.balanceOf(fundAddr), 35); + + // alice allowance will now be 15 + + // tries to commit 25 (allowance will fail) + vm.expectRevert(); + fund.commit(ALICE, OPT, tokenAddr, 25); + vm.stopPrank(); + + // alice raises her allowance greater than her balance, this is not something we control + vm.startPrank(ALICE); + // NOTE `approve` is overwriting. if available we could choose to use [in|de]creaseAllowance (not std interface however) + assertEq(token.approve(fundAddr, 500), true); + assertEq(token.allowance(ALICE, fundAddr), 500); + vm.stopPrank(); + + // tries to commit 400 (balance will fail) + vm.startPrank(ADMIN); + vm.expectRevert(); + fund.commit(ALICE, OPT, tokenAddr, 400); + + // alice's bookkeeping is unchanged since last successful commit + t = fund.totals(ALICE, OPT, tokenAddr); + assertEq(t.commitCount, 2); + assertEq(t.commitSum, 35); + assertEq(fund.commitBalance(ALICE, OPT, tokenAddr), 35); + + // tries to remit more than her balance + vm.expectRevert(); + fund.remit(ALICE, OPT, tokenAddr, 100); + + // admin remits alice 15 + assertEq(token.balanceOf(ALICE), 65); // alice pre remit token balance + assertEq(token.balanceOf(fundAddr), 35); // fund pre remit token bal + assertEq(fund.remit(ALICE, OPT, tokenAddr, 15), true); + + t = fund.totals(ALICE, OPT, tokenAddr); + assertEq(t.commitCount, 2); + assertEq(t.commitSum, 35); + assertEq(t.remitCount, 1); + assertEq(t.remitSum, 15); + // properly reflected in commit bal + assertEq(fund.commitBalance(ALICE, OPT, tokenAddr), 20); + assertEq(token.balanceOf(ALICE), 80); // post remit... + assertEq(token.balanceOf(fundAddr), 20); // post remit.. + vm.stopPrank(); + + // owner can approve SOMEBODY for whatevs + assertEq(fund.approve(SOMEBODY, address(token), 100), true); + assertEq(token.allowance(address(fund), SOMEBODY), 100); + } +} diff --git a/ethereum/test/superstate/BuyTheDip.t.sol b/ethereum/test/superstate/BuyTheDip.t.sol new file mode 100644 index 0000000..b48ba8b --- /dev/null +++ b/ethereum/test/superstate/BuyTheDip.t.sol @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +// import {console} from "forge-std/console.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {Preview, SwapTotal as Total} from "swap/Types.sol"; +import {Superstate} from "superstate/Superstate.sol"; +import {IDippable} from "superstate/IDippable.sol"; + +// create a faux ss output token which implements IDippable +contract BTD is TestToken, IDippable { + address private constant AUTHORIZED = 0x6060606060606060606060606060606060606060; + address public constant ALSO_AUTHORIZED = 0x8080808080808080808080808080808080808080; + address private constant DIP_CONTRACT = 0x7070707070707070707070707070707070707070; + + constructor(string memory n, string memory s) TestToken(n, s) {} + + function isAllowed(address user) external view override returns (bool) { + return user == AUTHORIZED || user == ALSO_AUTHORIZED; + } + + function dipContract() external view override returns (address) { + return DIP_CONTRACT; + } + + function buyTheDip(bytes32, uint256 amount, uint256 slip, address token) external override returns (uint256) { + // transferFrom the calling contract to here, should have been approved + require(IERC20(token).transferFrom(msg.sender, address(this), amount)); + // mint the slippage floor such that our ss swap contract can calculate a delta + _mint(msg.sender, slip); + + return slip; + } +} + +contract BuyTheDip is Test { + TestToken public inT; + BTD public outT; + Superstate public ss; + address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address public constant ALICE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + address public constant TIMMY = 0x8080808080808080808080808080808080808080; + bytes32 public constant MKT_ID = keccak256("abc-123"); + bytes32 public constant SALE_ID = keccak256("bcd-234"); + + bytes4 public constant TRANSFER_FROM_SELECTOR = bytes4(keccak256("transferFrom(address,address,uint256)")); + bytes4 public constant APPROVE_SELECTOR = bytes4(keccak256("approve(address,uint256)")); + bytes4 public constant IS_ALLOWED_SELECTOR = bytes4(keccak256("isAllowed(address)")); + bytes4 public constant CALCULATE_OUTPUT_SELECTOR = bytes4(keccak256("calculateOutput(bytes32,uint256,uint8)")); + + function setUp() public { + inT = new TestToken("InToken", "INTKN"); + outT = new BTD("outToken", "OUTKN"); + ss = new Superstate(address(outT), MKT_ID, SALE_ID); + // input tokens must be whitelisted + ss.setInputToken(address(inT), true); + } + + function testMarketId() public { + assertEq(ss.marketId(), MKT_ID); + } + + function testUSDCApproved() public { + assert(ss.inputTokens(USDC)); + } + + function testSetInputTokenValues() public { + assert(ss.inputTokens(address(inT))); + // can be unwhitelisted + ss.setInputToken(address(inT), false); + assertEq(ss.inputTokens(address(inT)), false); + } + + function testRevertPaused() public { + assert(ss.pause(ss.SWAP_LEVEL())); + vm.expectRevert(abi.encodeWithSignature("IsPaused(uint32)", ss.SWAP_LEVEL())); + ss.swap(address(inT), 100000000, 100); + } + + function testRevertStopped() public { + assert(ss.stop()); + vm.expectRevert(abi.encodeWithSignature("IsStopped()")); + ss.swap(address(inT), 100000000, 100); + } + + function testRevertInputToken() public { + vm.expectRevert(abi.encodeWithSignature("InvalidAddress()")); + ss.swap(TIMMY, 100000000, 100); + } + + function testRevertTransferFrom() public { + vm.mockCall(address(inT), abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(false)); + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 100])); + vm.expectRevert(); + vm.prank(ALICE); + ss.swap(address(inT), 100000000, 100); + } + + function testRevertApprove() public { + vm.mockCall(address(inT), abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(address(inT), abi.encodeWithSelector(APPROVE_SELECTOR), abi.encode(false)); + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 100])); + vm.expectRevert(); + vm.prank(ALICE); + ss.swap(address(inT), 100000000, 100); + + // no bookkeeping set + assert(zero(ALICE)); + } + + function testRevertMintedAmount() public { + vm.mockCall(address(inT), abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(address(inT), abi.encodeWithSelector(APPROVE_SELECTOR), abi.encode(true)); + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 100])); + // force bTD to return under the slippage + vm.mockCall( + address(outT), + abi.encodeWithSelector(BTD.buyTheDip.selector, MKT_ID, 100000000, 100, address(inT)), + abi.encode(99) + ); + + vm.expectRevert(abi.encodeWithSignature("InsufficientAmount()")); + vm.prank(ALICE); + ss.swap(address(inT), 100000000, 100); + + assert(zero(ALICE)); + } + + function testRevertBuyTheDip() public { + vm.mockCall(address(inT), abi.encodeWithSelector(TRANSFER_FROM_SELECTOR), abi.encode(true)); + vm.mockCall(address(inT), abi.encodeWithSelector(APPROVE_SELECTOR), abi.encode(true)); + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 100])); + + // force the call to bTD to revert + vm.mockCallRevert( + address(outT), + abi.encodeWithSelector(BTD.buyTheDip.selector, MKT_ID, 100000000, 100, address(inT)), + abi.encodeWithSignature("Error(string)", "nope") + ); + + vm.expectRevert(); + vm.prank(ALICE); + ss.swap(address(inT), 100000000, 100); + + assert(zero(ALICE)); + } + + function testAuthorized() public { + assert(ss.authorized(ALICE)); + assertEq(ss.authorized(BOB), false); + } + + function testPreview() public { + // BTD will call its dip contract... + vm.mockCall( + outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 200000000]) + ); + + // it wont matter that decimals is 18 here.. + Preview memory pre = ss.preview(address(inT), 100000000); + assertEq(pre.input, 100000000); + assertEq(pre.fee, 0); + assertEq(pre.output, 200000000); + } + + function testBuyTheDip() public { + // alice needs balance of input token + inT.mint(ALICE, 200000000); + assertEq(inT.balanceOf(ALICE), 200000000); + + // alice must approve the ss contract + vm.prank(ALICE); + assert(inT.approve(address(ss), 100000000)); + assertEq(inT.allowance(ALICE, address(ss)), 100000000); + + // atm the output token has no input token balance + assertEq(inT.balanceOf(address(outT)), 0); + + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 100])); + + vm.prank(ALICE); + uint256 minted = ss.swap(address(inT), 100000000, 100); + assertEq(minted, 100); + + // the output token should now have alices input amount + assertEq(inT.balanceOf(address(outT)), 100000000); + + Total memory t = ss.totals(ALICE, address(inT)); + + // NOTE: the foundry tracer (-vvvv) has lost its fucking mind, leave these logs in as comments for now + // NOTE: re: it is showing the SaleTotal struct here in the trace, no idea why, will be reporting to them + // console.log(t.count); + // console.log(t.inputSum); + + assertEq(t.count, 1); + assertEq(t.inputSum, 100000000); + assertEq(t.outputSum, 100); + assertEq(t.feeSum, 0); + + // the contract totals will mirror alice's here + t = ss.totals(address(inT)); + assertEq(t.count, 1); + assertEq(t.inputSum, 100000000); + assertEq(t.outputSum, 100); + assertEq(t.feeSum, 0); + + // now timmy... + + inT.mint(TIMMY, 400000000); + assertEq(inT.balanceOf(TIMMY), 400000000); + + vm.prank(TIMMY); + assert(inT.approve(address(ss), 200000000)); + assertEq(inT.allowance(TIMMY, address(ss)), 200000000); + + // atm the output token has alice's + assertEq(inT.balanceOf(address(outT)), 100000000); + + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([200000000, 200])); + + vm.prank(TIMMY); + minted = ss.swap(address(inT), 200000000, 200); + assertEq(minted, 200); + + // the output token should now have alice's and timmy's input amount + assertEq(inT.balanceOf(address(outT)), 300000000); + + t = ss.totals(TIMMY, address(inT)); + + assertEq(t.count, 1); + assertEq(t.inputSum, 200000000); + assertEq(t.outputSum, 200); + assertEq(t.feeSum, 0); + + // the contract totals will reflects alice's and bob's now + t = ss.totals(address(inT)); + assertEq(t.count, 2); + assertEq(t.inputSum, 300000000); + assertEq(t.outputSum, 300); + assertEq(t.feeSum, 0); + + // in a zero-fee scenario we hold no balance of the input token + assertEq(ss.tokenBalance(address(inT)), 0); + } + + function testBuyTheDipWithFee() public { + assert(ss.setBps(100)); + + // alice needs balance of input token + inT.mint(ALICE, 200000000); + assertEq(inT.balanceOf(ALICE), 200000000); + + // alice must approve the ss contract + vm.prank(ALICE); + assert(inT.approve(address(ss), 100000000)); + assertEq(inT.allowance(ALICE, address(ss)), 100000000); + + // atm the output token has no input token balance + assertEq(inT.balanceOf(address(outT)), 0); + + // we can know alice's fee ahead of time + (uint256 fee, uint256 aliceInputSum) = ss.fee(100000000); + + // mock the calc output call, don't send back more than passed in.. + vm.mockCall( + outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([aliceInputSum, 100]) + ); + + vm.prank(ALICE); + uint256 minted = ss.swap(address(inT), 100000000, 100); + assertEq(minted, 100); + + // the output token should now have alices input amount + assertEq(inT.balanceOf(address(outT)), aliceInputSum); + + Total memory t = ss.totals(ALICE, address(inT)); + + assertEq(t.count, 1); + assertEq(t.inputSum, aliceInputSum); + assertEq(t.outputSum, 100); + assertEq(t.feeSum, fee); + + // the contract totals will mirror alice's here + t = ss.totals(address(inT)); + assertEq(t.count, 1); + assertEq(t.inputSum, aliceInputSum); + assertEq(t.outputSum, 100); + assertEq(t.feeSum, fee); + + // now timmy... + inT.mint(TIMMY, 400000000); + assertEq(inT.balanceOf(TIMMY), 400000000); + + vm.prank(TIMMY); + assert(inT.approve(address(ss), 200000000)); + assertEq(inT.allowance(TIMMY, address(ss)), 200000000); + + uint256 prevfee = fee; + + // timmy's fee amounts + uint256 timmyInputSum; + (fee, timmyInputSum) = ss.fee(200000000); + + // timmy's returned amount needs to be set.. + vm.mockCall( + outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([timmyInputSum, 200]) + ); + + vm.prank(TIMMY); + minted = ss.swap(address(inT), 200000000, 200); + assertEq(minted, 200); + + // the output token should now have alice's and timmy's input amount + assertEq(inT.balanceOf(address(outT)), aliceInputSum + timmyInputSum); + + t = ss.totals(TIMMY, address(inT)); + + assertEq(t.count, 1); + assertEq(t.inputSum, timmyInputSum); + assertEq(t.outputSum, 200); + assertEq(t.feeSum, fee); + + // the contract totals will reflects alice's and timmy's now + t = ss.totals(address(inT)); + assertEq(t.count, 2); + assertEq(t.inputSum, aliceInputSum + timmyInputSum); + assertEq(t.outputSum, 300); + assertEq(t.feeSum, prevfee + fee); + + // in a fee collecting scenario, until transferred, the input token balance will reflect the feeSum + assertEq(ss.tokenBalance(address(inT)), t.feeSum); + } + + function testSuccessiveSwaps() public { + // run swaps in quick succession assuring balance is not falsely reported + // contract calls are atomic so this isn't technically necessary... + inT.mint(ALICE, 200000000); + inT.mint(TIMMY, 300000000); + + vm.prank(ALICE); + assert(inT.approve(address(ss), 100000000)); + vm.prank(TIMMY); + assert(inT.approve(address(ss), 200000000)); + + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([100000000, 100])); + vm.prank(ALICE); + uint256 aliceMinted = ss.swap(address(inT), 100000000, 100); + + vm.mockCall(outT.dipContract(), abi.encodeWithSelector(CALCULATE_OUTPUT_SELECTOR), abi.encode([200000000, 200])); + vm.prank(TIMMY); + uint256 bobMinted = ss.swap(address(inT), 200000000, 200); + + // if the calls were not atomic we'd accidentally txfer bob more than his 200 as the delta could be wrong + assertEq(aliceMinted, 100); + assertEq(bobMinted, 200); + } + + // ************************* Utility ************************** + + function zero(address user) internal view returns (bool) { + Total memory t = ss.totals(user, address(inT)); + return t.count == 0 && t.inputSum == 0 && t.outputSum == 0 && t.feeSum == 0; + } +} diff --git a/ethereum/test/superstate/MockTheDip.sol b/ethereum/test/superstate/MockTheDip.sol new file mode 100644 index 0000000..79b441d --- /dev/null +++ b/ethereum/test/superstate/MockTheDip.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {Market, MarketState} from "superstate/Types.sol"; + +contract MockTheDip is TestToken { + bool public shouldRevert = false; + + /// @dev 1e6 decimal token by default + uint256 public calculatedOutput = 1000000; + /// @dev if non zero will return as actual amount spent + uint256 public calculatedInput = 0; + + mapping(address => bool) private _whitelist; + + mapping(bytes32 => Market) public markets; + + constructor(string memory n, string memory s) TestToken(n, s) {} + + /// @notice override the 18 decimals to a stable coin 6 + function decimals() public pure override returns (uint8) { + return 6; + } + + /// @notice this contract will also serve as the IDip + function dipContract() external view returns (address) { + return address(this); + } + + /// @notice checks the _whitelist + function isAllowed(address addr) external view returns (bool) { + return _whitelist[addr]; + } + + /// @notice mimic the ss buyTheDip call. we will transfer in the amount and mint sender the set calculatedAmount + function buyTheDip(bytes32, uint256 amount, uint256, address token) external returns (uint256) { + // force fail if desired.. + require(!shouldRevert, "womp womp"); + // caller must be whitelisted + require(_whitelist[msg.sender], "unauthorized"); + // transferFrom the calling contract to here, *should have been approved* + require(IERC20(token).transferFrom(msg.sender, address(this), amount)); + + _mint(msg.sender, calculatedOutput); + + return calculatedOutput; + } + + /// @notice IRL is a separate contract implementing the SS IDip interface, this is fine for mocking + function calculateOutput(bytes32, uint256 amount, uint8) external view returns (uint256, uint256) { + // not really needed on a read, but could happen with these args and the large amt of code on their side + require(!shouldRevert, "womp womp"); + + uint256 spent = calculatedInput > 0 ? calculatedInput : amount; + return (spent, calculatedOutput); + } + + /// @notice set the calculatedOutput to another value + function setCalculatedOutput(uint256 amount) external returns (bool) { + calculatedOutput = amount; + return true; + } + + function setCalculatedInput(uint256 amount) external returns (bool) { + calculatedInput = amount; + return true; + } + + function setShouldRevert(bool state) external returns (bool) { + shouldRevert = state; + return true; + } + + /// @notice given an address and a boolean set the values into the whitelist + function whitelist(address addr, bool auth) external returns (bool) { + _whitelist[addr] = auth; + return true; + } + + /// @notice set a new market into the mapping or change the value of an existing one + function setMarket(bytes32 marketId, MarketState state) external returns (bool) { + Market storage mkt = markets[marketId]; + mkt.state = state; + return true; + } +} diff --git a/ethereum/test/superstate/Status.t.sol b/ethereum/test/superstate/Status.t.sol new file mode 100644 index 0000000..37cbcae --- /dev/null +++ b/ethereum/test/superstate/Status.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {State, Status} from "shared/operable/Types.sol"; +import {Superstate} from "superstate/Superstate.sol"; +import {Market, MarketState} from "superstate/Types.sol"; + +contract StatusTest is Test { + TestToken public inT; + // we'll just stub the ss specific calls in this test + TestToken public outT; + Superstate public ss; + + bytes32 public constant MKT_ID = keccak256("abc-123"); + bytes32 public constant SALE_ID = keccak256("bcd-234"); + + address private constant DIP_CONTRACT = 0x7070707070707070707070707070707070707070; + bytes4 public constant DIP_CONTRACT_SELECTOR = bytes4(keccak256("dipContract()")); + bytes4 public constant MARKETS_SELECTOR = bytes4(keccak256("markets(bytes32)")); + + function setUp() public { + inT = new TestToken("InToken", "INTKN"); + outT = new TestToken("outToken", "OUTKN"); + ss = new Superstate(address(outT), MKT_ID, SALE_ID); + } + + function testStatus() public { + vm.mockCall(address(outT), abi.encodeWithSelector(DIP_CONTRACT_SELECTOR), abi.encode(DIP_CONTRACT)); + // initialized by default + Market memory mkt; + vm.mockCall(DIP_CONTRACT, abi.encodeWithSelector(MARKETS_SELECTOR, MKT_ID), abi.encode(mkt)); + + // initialized + Status memory stat = ss.status(); + assertEq(uint8(stat.state), uint8(State.Paused)); + + // paused + mkt.state = MarketState.Paused; + vm.mockCall(DIP_CONTRACT, abi.encodeWithSelector(MARKETS_SELECTOR, MKT_ID), abi.encode(mkt)); + stat = ss.status(); + assertEq(uint8(stat.state), uint8(State.Paused)); + + // active + mkt.state = MarketState.Active; + vm.mockCall(DIP_CONTRACT, abi.encodeWithSelector(MARKETS_SELECTOR, MKT_ID), abi.encode(mkt)); + stat = ss.status(); + assertEq(uint8(stat.state), uint8(State.Active)); + + // closed + mkt.state = MarketState.Closed; + vm.mockCall(DIP_CONTRACT, abi.encodeWithSelector(MARKETS_SELECTOR, MKT_ID), abi.encode(mkt)); + stat = ss.status(); + assertEq(uint8(stat.state), uint8(State.Stopped)); + + // cancelled + mkt.state = MarketState.Cancelled; + vm.mockCall(DIP_CONTRACT, abi.encodeWithSelector(MARKETS_SELECTOR, MKT_ID), abi.encode(mkt)); + stat = ss.status(); + assertEq(uint8(stat.state), uint8(State.Stopped)); + + // setting any non active state on our own contract would take precedence + assert(ss.pause(ss.SWAP_LEVEL())); + stat = ss.status(); + assertEq(uint8(stat.state), uint8(State.Paused)); + } +} diff --git a/ethereum/test/superstate/integration/Superstate.t.sol b/ethereum/test/superstate/integration/Superstate.t.sol new file mode 100644 index 0000000..9dae49c --- /dev/null +++ b/ethereum/test/superstate/integration/Superstate.t.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {Status, State} from "shared/operable/Types.sol"; +import {Preview} from "swap/Types.sol"; +import {Superstate as SS} from "superstate/Superstate.sol"; +import {MarketState} from "superstate/Types.sol"; +import {MockTheDip} from "../MockTheDip.sol"; + +contract SuperState is Test { + TestToken public inT; + MockTheDip public mock; + SS public ss; + + address public constant ALICE = 0x6060606060606060606060606060606060606060; + address public constant BOB = 0x7070707070707070707070707070707070707070; + bytes32 public constant MKT_ID = keccak256("abc-123"); + bytes32 public constant SALE_ID = keccak256("bcd-234"); + + function setUp() public { + inT = new TestToken("InToken", "INTKN"); + mock = new MockTheDip("outToken", "OUTKN"); + ss = new SS(address(mock), MKT_ID, SALE_ID); + ss.setInputToken(address(inT), true); + } + + function testMarketId() public { + assertEq(ss.marketId(), MKT_ID); + } + + function testDecimals() public { + assertEq(mock.decimals(), 6); + } + + function testDipContract() public { + // just returns itself + assertEq(mock.dipContract(), address(mock)); + } + + function testMockAddr() public { + assertEq(ss.outputToken(), address(mock)); + } + + function testCalculatedOutput() public { + // is 1e6 by default + assertEq(mock.calculatedOutput(), 1000000); + // can be set + assert(mock.setCalculatedOutput(2000000)); + assertEq(mock.calculatedOutput(), 2000000); + } + + function testCalculatedInput() public { + // is 0 by default + assertEq(mock.calculatedInput(), 0); + // can be set + assert(mock.setCalculatedInput(100)); + assertEq(mock.calculatedInput(), 100); + } + + function testPreview() public { + // will return what is passed as spent by default, along with set calculatedAmount + Preview memory pre = ss.preview(address(inT), 100); + assertEq(pre.input, 100); + assertEq(pre.output, 1000000); + assertEq(pre.fee, 0); + + // with set input, will return that vs passed in amt + assert(mock.setCalculatedInput(500)); + pre = ss.preview(address(inT), 1000); + assertEq(pre.input, 500); + } + + function testWhitelist() public { + // alice is not by default + assertEq(ss.authorized(ALICE), false); + + // allow + assert(mock.whitelist(ALICE, true)); + assert(ss.authorized(ALICE)); + + // disallow + assert(mock.whitelist(ALICE, false)); + assertEq(ss.authorized(ALICE), false); + } + + function testRevertState() public { + // is false by default + assertEq(mock.shouldRevert(), false); + // can be set + assert(mock.setShouldRevert(true)); + assert(mock.shouldRevert()); + // calculateAmount will now revert + vm.expectRevert(); + ss.preview(address(inT), 100); + } + + function testMarketState() public { + // set the market into the mapping + assert(mock.setMarket(ss.marketId(), MarketState.Initialized)); + // will be paused as ss hasn't started this market yet.. + Status memory stat = ss.status(); + assertEq(uint8(stat.state), 1); + assertEq(uint32(stat.flags), 0); + + // once active ours will correct + assert(mock.setMarket(ss.marketId(), MarketState.Active)); + stat = ss.status(); + assertEq(uint8(stat.state), 0); + } + + function testRevertBuyTheDipUnauthorized() public { + // alice would need balance of input token + inT.mint(ALICE, 200000000); + + // alice must approve the ss contract + vm.prank(ALICE); + assert(inT.approve(address(ss), 100000000)); + + // minus a whitelisting step for the ss contract here, it should revert + vm.expectRevert(); + vm.prank(ALICE); + ss.swap(address(inT), 100000000, 100); + } + + function testBuyTheDip() public { + // alice needs balance of input token + inT.mint(ALICE, 200000000); + assertEq(inT.balanceOf(ALICE), 200000000); + + // alice must approve the ss contract + vm.prank(ALICE); + assert(inT.approve(address(ss), 100000000)); + assertEq(inT.allowance(ALICE, address(ss)), 100000000); + + // atm the mock output token has no input token balance + assertEq(inT.balanceOf(address(mock)), 0); + + // ss contract must be whitelisted + assert(mock.whitelist(address(ss), true)); + assert(ss.authorized(address(ss))); + + // alice must be whitelisted + assert(mock.whitelist(ALICE, true)); + assert(ss.authorized(ALICE)); + + vm.prank(ALICE); + uint256 minted = ss.swap(address(inT), 100000000, 100); + // will mint the default calculatedAmount + assertEq(minted, 1000000); + } + + // changing calc amount will result in that being the delta + function testBuyTheDipCalculated() public { + // alice needs balance of input token + inT.mint(ALICE, 200000000); + assertEq(inT.balanceOf(ALICE), 200000000); + + // alice must approve the ss contract + vm.prank(ALICE); + assert(inT.approve(address(ss), 100000000)); + assertEq(inT.allowance(ALICE, address(ss)), 100000000); + + // atm the mock output token has no input token balance + assertEq(inT.balanceOf(address(mock)), 0); + + // ss contract must be whitelisted + assert(mock.whitelist(address(ss), true)); + + // and alice... + assert(mock.whitelist(ALICE, true)); + + // this should then be the reported mint amount + assert(mock.setCalculatedOutput(6767)); + + vm.prank(ALICE); + uint256 minted = ss.swap(address(inT), 100000000, 100); + // will mint the default calculatedAmount + assertEq(minted, 6767); + } +} diff --git a/ethereum/test/swap/DefaultState.t.sol b/ethereum/test/swap/DefaultState.t.sol new file mode 100644 index 0000000..64b51ed --- /dev/null +++ b/ethereum/test/swap/DefaultState.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSwap} from "swap/TokenSwap.sol"; +import {Preview} from "swap/Types.sol"; + +// base contract is abstract, thus we must create child to deploy +contract DSwap is TokenSwap { + constructor(address token, bytes32 swapId) TokenSwap(token, swapId) {} + + function swap(address, uint256 amount, uint256) external override returns (uint256) { + // just return some bs + return amount; + } + + function authorized(address user) external view override returns (bool) { + return false; + } + + function preview(address, uint256) external view override returns (Preview memory) { + return Preview(0, 0, 0); + } +} + +contract SwapDefault is Test { + TestToken public outT; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + bytes32 public constant SALE_ID = keccak256("abc-123"); + + function setUp() public { + outT = new TestToken("outToken", "OUTKN"); + } + + function testRevertZeroOutToken() public { + vm.expectRevert(); + new DSwap(address(0), SALE_ID); + } + + function testRevertNotContractOutToken() public { + vm.expectRevert(); + new DSwap(SOMEONE, SALE_ID); + } + + function testConstructionGetters() public { + DSwap swap = new DSwap(address(outT), SALE_ID); + + // is owned + assertEq(swap.owner(), address(this)); + // is not paused + assertEq(swap.paused(), 0); + // is not stopped + assertEq(swap.stopped(), false); + + assertEq(swap.outputToken(), address(outT)); + assertEq(swap.id(), SALE_ID); + + // fee is zero, amount is unchanged + (uint256 fee, uint256 adj) = swap.fee(1000000); + assertEq(fee, 0); + assertEq(adj, 1000000); + } + + function testRevertPauseWhenNotOwner() public { + DSwap swap = new DSwap(address(outT), SALE_ID); + + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + vm.prank(SOMEONE); + + swap.pause(2); + } + + function testRevertStopWhenNotOwner() public { + DSwap swap = new DSwap(address(outT), SALE_ID); + + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + vm.prank(SOMEONE); + + swap.stop(); + } + + function testPausable() public { + DSwap swap = new DSwap(address(outT), SALE_ID); + swap.pause(42); + assertEq(swap.paused(), 42); + } + + function testStopable() public { + DSwap swap = new DSwap(address(outT), SALE_ID); + swap.stop(); + assert(swap.stopped()); + } +} diff --git a/ethereum/test/swap/Fee.t.sol b/ethereum/test/swap/Fee.t.sol new file mode 100644 index 0000000..25d5786 --- /dev/null +++ b/ethereum/test/swap/Fee.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSwap} from "swap/TokenSwap.sol"; +import {Preview} from "swap/Types.sol"; + +// base contract is abstract, thus we must create child to deploy +contract FSwap is TokenSwap { + constructor(address token, bytes32 swapId) TokenSwap(token, swapId) {} + + function swap(address, uint256 amount, uint256) external override returns (uint256) { + (uint256 fee,) = super.fee(amount); + return fee; + } + + function authorized(address user) external view override returns (bool) { + return false; + } + + function preview(address, uint256) external view override returns (Preview memory) { + return Preview(0, 0, 0); + } +} + +contract Fee is Test { + TestToken public outT; + address public constant SOMEONE = 0x6060606060606060606060606060606060606060; + bytes32 public constant SALE_ID = keccak256("abc-123"); + FSwap public swap; + + function setUp() public { + outT = new TestToken("outToken", "OUTKN"); + swap = new FSwap(address(outT), SALE_ID); + } + + function testRevertNotOwnerSetBps() public { + vm.expectRevert(); + vm.prank(SOMEONE); + swap.setBps(85); + } + + function testSetBps() public { + assertEq(swap.setBps(85), true); + assertEq(swap.bps(), 85); + } + + function testRevertExceedsSetBps() public { + // cannot exceed 100% + vm.expectRevert(); + swap.setBps(11000); + + // cannot meet 100% + vm.expectRevert(); + swap.setBps(10000); + } + + function testFeeIsZero() public { + // with no bps in place, fee will always be zero + (uint256 fee, uint256 adj) = swap.fee(1000000); + assertEq(fee, 0); + assertEq(adj, 1000000); + } + + function testFee() public { + assertEq(swap.setBps(85), true); + uint256 total = 100000000; + (uint256 fee, uint256 adj) = swap.fee(total); + // fee is total - adj + assert(total - adj == fee); + // we should see the two amounts adding back up to the given total + assert(fee + adj == total); + } +} diff --git a/ethereum/test/swap/transfers.t.sol b/ethereum/test/swap/transfers.t.sol new file mode 100644 index 0000000..ab63b39 --- /dev/null +++ b/ethereum/test/swap/transfers.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {TestToken} from "shared/TestToken.sol"; +import {TokenSwap} from "swap/TokenSwap.sol"; +import {Preview} from "swap/Types.sol"; + +// base contract is abstract, thus we must create child to deploy +contract TSwap is TokenSwap { + constructor(address token, bytes32 swapId) TokenSwap(token, swapId) {} + + function swap(address, uint256, uint256) external override returns (uint256) { + return 0; + } + + function authorized(address user) external view override returns (bool) { + return false; + } + + function preview(address, uint256) external view override returns (Preview memory) { + return Preview(0, 0, 0); + } +} + +contract Transfers is Test { + TestToken public inT; + TestToken public outT; + address public constant SOMEONE = 0x1010101010101010101010101010101010101010; + address public constant SOMEONE_ELSE = 0x6060606060606060606060606060606060606060; + bytes32 public constant SALE_ID = keccak256("abc-123"); + TSwap public swap; + + function setUp() public { + inT = new TestToken("InToken", "INTKN"); + outT = new TestToken("outToken", "OUTKN"); + swap = new TSwap(address(outT), SALE_ID); + + // mint the swap some bal.. + inT.mint(address(swap), 100); + outT.mint(address(swap), 100); + } + + function testRevertNotOwnerTxferInput() public { + vm.expectRevert(); + vm.prank(SOMEONE); + + swap.transfer(SOMEONE_ELSE, address(inT), 10000000000); + } + + function testRevertNotOwnerTxferOutput() public { + vm.expectRevert(); + vm.prank(SOMEONE); + + swap.transfer(SOMEONE_ELSE, 10000000000); + } + + function testTxferInput() public { + assertEq(swap.tokenBalance(address(inT)), 100); + assertEq(inT.balanceOf(SOMEONE), 0); + assert(swap.transfer(SOMEONE, address(inT), 50)); + assertEq(inT.balanceOf(SOMEONE), 50); + assertEq(swap.tokenBalance(address(inT)), 50); + } + + function testTransferOutput() public { + assertEq(swap.outputTokenBalance(), 100); + assertEq(outT.balanceOf(SOMEONE), 0); + assert(swap.transfer(SOMEONE, 50)); + assertEq(outT.balanceOf(SOMEONE), 50); + assertEq(swap.outputTokenBalance(), 50); + } +}