diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 449de7dc..7c4918fe 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -53,7 +53,7 @@ jobs: run: yarn - name: Run coverage - run: forge coverage --match-path "test/{Swarm*,ServiceProvider,FleetIdentity,collections/*}*.t.sol" --ir-minimum --report lcov --report-file coverage.lcov + run: forge coverage --match-path "test/{Swarm*,ServiceProvider,FleetIdentity,collections/*,tipping/*}*.t.sol" --ir-minimum --report lcov --report-file coverage.lcov - name: Upload coverage report uses: actions/upload-artifact@v4 @@ -144,6 +144,39 @@ jobs: echo "Coverage check passed: $COVERAGE% >= $THRESHOLD%" + - name: Check line coverage threshold (tipping) + run: | + # Extract line coverage from lcov report for src/tipping/ contracts only + LINES_FOUND=$(awk ' + /^SF:.*src\/tipping\// { in_section = 1 } + /^end_of_record/ { in_section = 0 } + in_section && /^LF:/ { sum += substr($0, 4) } + END { print sum+0 } + ' coverage.lcov) + + LINES_HIT=$(awk ' + /^SF:.*src\/tipping\// { in_section = 1 } + /^end_of_record/ { in_section = 0 } + in_section && /^LH:/ { sum += substr($0, 4) } + END { print sum+0 } + ' coverage.lcov) + + if [ "$LINES_FOUND" -eq 0 ]; then + echo "Error: No lines found in coverage report for src/tipping/" + exit 1 + fi + + COVERAGE=$(awk "BEGIN {printf \"%.2f\", ($LINES_HIT / $LINES_FOUND) * 100}") + echo "Tipping line coverage: $COVERAGE% ($LINES_HIT / $LINES_FOUND lines)" + + THRESHOLD=95 + if awk "BEGIN {exit !($COVERAGE < $THRESHOLD)}"; then + echo "Error: Line coverage ($COVERAGE%) is below the required threshold ($THRESHOLD%)" + exit 1 + fi + + echo "Coverage check passed: $COVERAGE% >= $THRESHOLD%" + Specification-PDF: runs-on: ubuntu-latest if: github.event_name == 'pull_request' && github.base_ref == 'main' diff --git a/ops/deploy_tipping_zksync.sh b/ops/deploy_tipping_zksync.sh new file mode 100755 index 00000000..a7810007 --- /dev/null +++ b/ops/deploy_tipping_zksync.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Deploys the immutable Tipping contract on zkSync (Sepolia or mainnet) with forge --zksync. +# zksolc cannot compile the L1-only SwarmRegistryL1Upgradeable (EXTCODECOPY), so the L1-only +# files are moved aside for the compile and restored on exit, exactly as +# ops/deploy_collection_factory_zksync.sh and ops/upgrade_collection_factory_zksync.sh do. +# +# Usage: +# DEPLOYER_PRIVATE_KEY=0x… N_TIPPING_ADMIN=0x… \ +# ops/deploy_tipping_zksync.sh [--broadcast] +# +# Without --broadcast the script simulates only. The broadcast JSON lands in +# broadcast/DeployTippingZkSync.s.sol//run-latest.json; verify afterwards with +# python3 ops/verify_zksync_contracts.py --broadcast . +set -euo pipefail +cd "$(dirname "$0")/.." + +NETWORK="${1:-}"; BROADCAST="${2:-}" +case "$BROADCAST" in + ""|--broadcast) ;; + *) echo "usage: $0 [--broadcast] (second argument must be exactly --broadcast or omitted)"; exit 1 ;; +esac +case "$NETWORK" in + testnet) RPC_URL="${L2_RPC:-https://sepolia.era.zksync.dev}" ;; + mainnet) RPC_URL="${L2_RPC:-https://mainnet.era.zksync.io}" ;; + *) echo "usage: $0 [--broadcast]"; exit 1 ;; +esac +: "${DEPLOYER_PRIVATE_KEY:?}" "${N_TIPPING_ADMIN:?}" +[[ "$DEPLOYER_PRIVATE_KEY" == 0x* ]] || export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" +if [[ "$NETWORK" == "mainnet" && "$BROADCAST" == "--broadcast" && "${CONFIRM_MAINNET:-}" != "YES" ]]; then + echo "mainnet broadcast requires CONFIRM_MAINNET=YES"; exit 1 +fi + +L1_BACKUP_DIR="/tmp/rollup-l1-backup-tipping" +restore_l1_contracts() { + [ -d "$L1_BACKUP_DIR" ] || return 0 + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" ] && mv "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" src/swarms/ + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" ] && mv "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" test/ + [ -d "$L1_BACKUP_DIR/upgrade-demo" ] && mv "$L1_BACKUP_DIR/upgrade-demo" test/ + [ -f "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" ] && mv "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" script/ + [ -f "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" ] && mv "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" script/ + rm -rf "$L1_BACKUP_DIR" + echo "L1-only contracts restored" +} +move_l1_contracts() { + [ -d "$L1_BACKUP_DIR" ] && restore_l1_contracts + mkdir -p "$L1_BACKUP_DIR" + [ -f src/swarms/SwarmRegistryL1Upgradeable.sol ] && mv src/swarms/SwarmRegistryL1Upgradeable.sol "$L1_BACKUP_DIR/" + [ -f test/SwarmRegistryL1.t.sol ] && mv test/SwarmRegistryL1.t.sol "$L1_BACKUP_DIR/" + [ -d test/upgrade-demo ] && mv test/upgrade-demo "$L1_BACKUP_DIR/" + [ -f script/DeploySwarmUpgradeable.s.sol ] && mv script/DeploySwarmUpgradeable.s.sol "$L1_BACKUP_DIR/" + [ -f script/UpgradeSwarm.s.sol ] && mv script/UpgradeSwarm.s.sol "$L1_BACKUP_DIR/" + echo "L1-only contracts moved aside for the zksync compile" +} +trap restore_l1_contracts EXIT +move_l1_contracts + +echo "network=$NETWORK rpc=$RPC_URL admin=$N_TIPPING_ADMIN broadcast=${BROADCAST:-no}" +forge script script/DeployTippingZkSync.s.sol --rpc-url "$RPC_URL" --zksync --skip test ${BROADCAST:+--broadcast --slow} diff --git a/ops/verify_zksync_contracts.py b/ops/verify_zksync_contracts.py index 7be8463f..ec07e1a7 100755 --- a/ops/verify_zksync_contracts.py +++ b/ops/verify_zksync_contracts.py @@ -85,6 +85,7 @@ "FleetIdentityUpgradeable": "src/swarms/FleetIdentityUpgradeable.sol:FleetIdentityUpgradeable", "SwarmRegistryUniversalUpgradeable": "src/swarms/SwarmRegistryUniversalUpgradeable.sol:SwarmRegistryUniversalUpgradeable", "ERC1967Proxy": "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy", + "Tipping": "src/tipping/Tipping.sol:Tipping", "BondTreasuryPaymaster": "src/paymasters/BondTreasuryPaymaster.sol:BondTreasuryPaymaster", "CollectionFactory": "src/collections/CollectionFactory.sol:CollectionFactory", "UserCollection721": "src/collections/UserCollection721.sol:UserCollection721", @@ -97,6 +98,7 @@ # with unnamed `additionalContracts`. For those scripts, recover the deployed # contract type by the deterministic deployment order inside the script. BROADCAST_CONTRACT_SEQUENCE = { + "DeployTippingZkSync.s.sol": ["Tipping"], "DeployEnvelopeZkSync.s.sol": [ "EnvelopeLinks", "EnvelopePaymaster", @@ -166,6 +168,44 @@ def fix_traversal_imports(std_json: dict) -> int: return changed +def apply_remappings(std_json: dict) -> int: + """Inline Foundry remappings into import statements and drop `settings.remappings`. + + Since 2026-10 the explorer verifier rejects `settings.remappings` ("imports must match + `sources` keys exactly"). Source keys are already project-relative (e.g. + lib/openzeppelin-contracts/contracts/...), so rewriting `@openzeppelin/contracts/X` to + its remapped path makes every import match a key. Longest remapping prefix wins. + Returns the number of import lines rewritten. + """ + remappings = std_json.get("settings", {}).pop("remappings", None) or [] + pairs = [] + for r in remappings: + if "=" in r: + prefix, target = r.split("=", 1) + pairs.append((prefix, target)) + pairs.sort(key=lambda x: -len(x[0])) + source_keys = set(std_json["sources"].keys()) + # Whole-statement match: imports may span lines (`import {A, B} from\n "@openzeppelin/..."`). + pattern = re.compile(r'(\bimport\b[^;]*?["\'])([^"\'.][^"\']*)(["\'])', re.S) + changed = 0 + + def rewrite(m: "re.Match[str]") -> str: + nonlocal changed + prefix, path, quote = m.groups() + for rp, target in pairs: + if path.startswith(rp): + new_path = target + path[len(rp):] + if new_path in source_keys: + changed += 1 + return prefix + new_path + quote + break + return m.group(0) + + for src_key in list(std_json["sources"].keys()): + std_json["sources"][src_key]["content"] = pattern.sub(rewrite, std_json["sources"][src_key]["content"]) + return changed + + def generate_standard_json( address: str, contract: str, @@ -258,7 +298,7 @@ def verify_one( std_json = generate_standard_json( address, contract, constructor_args, solc_version, project_root ) - fixes = fix_traversal_imports(std_json) + fixes = apply_remappings(std_json) + fix_traversal_imports(std_json) print(f"done (rewrote {fixes} imports)") # Step 2: Submit diff --git a/script/DeployTippingZkSync.s.sol b/script/DeployTippingZkSync.s.sol new file mode 100644 index 00000000..f9efa434 --- /dev/null +++ b/script/DeployTippingZkSync.s.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; + +import {Tipping} from "../src/tipping/Tipping.sol"; + +/** + * @title DeployTippingZkSync + * @notice Deploys the immutable `Tipping` contract (nft-feed-indexer design §6.2, meta#242). + * No proxy, no initializer: the constructor sets everything, so a mistaken input + * means a new deployment, not an upgrade. Fee starts at 0 with the admin as recipient. + * + * Usage: ops/deploy_tipping_zksync.sh [--broadcast] + * (moves the L1-only swarm files aside; plain `forge script --zksync` fails to compile) + * + * Environment Variables: + * - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas (gets no role). + * - N_TIPPING_ADMIN: Multisig that will hold DEFAULT_ADMIN_ROLE (fee, fee recipient). + */ +contract DeployTippingZkSync is Script { + address public tipping; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address admin = vm.envAddress("N_TIPPING_ADMIN"); + require(admin != address(0), "N_TIPPING_ADMIN is zero"); + + console.log("=== Deploying Tipping on ZkSync ==="); + console.log("Admin:", admin); + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + tipping = address(new Tipping(admin)); + vm.stopBroadcast(); + + console.log("Tipping:", tipping); + console.log(""); + console.log("Next: record the address and deploy block in the indexer (chains.ts `tipping`)."); + } +} diff --git a/src/tipping/ITipping.sol b/src/tipping/ITipping.sol new file mode 100644 index 00000000..cd88ee01 --- /dev/null +++ b/src/tipping/ITipping.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +/** + * @title ITipping + * @notice Interface of Nodle Tipping v2: tip an NFT post or an account with any ERC-20. + * A post tip names its recipient and the contract verifies the claim against the NFT + * (ERC-721 owner; ERC-1155 collection owner role, Ownable owner or ERC-2981 receiver). + * No backend signature, no allowlist, no pause. Keep the event surface stable: the + * Nodle feed indexer consumes it. + */ +interface ITipping { + enum NFTStandard { + ERC721, + ERC1155 + } + + /// @notice A post was tipped. `amount` is gross (what the tipper gave); `fee` went to the fee recipient. + event Tipped( + address indexed tipper, + address indexed collection, + uint256 indexed tokenId, + address token, + address recipient, + uint256 amount, + uint256 fee + ); + /// @notice An account was tipped directly, not tied to a post. + event AccountTipped(address indexed tipper, address indexed to, address token, uint256 amount, uint256 fee); + event FeeUpdated(uint16 feeBps); + event FeeRecipientUpdated(address indexed feeRecipient); + /// @notice Accrued fees in `token` were sent to the fee recipient. + event FeesWithdrawn(address indexed token, address indexed to, uint256 amount); + + error ZeroAddress(); + error ZeroAmount(); + error SelfTip(); + error NotAContract(address collection); + /// @notice The NFT does not exist for the given standard (ownerOf reverted, or an ERC-1155 id with zero supply). + error InvalidToken(address collection, uint256 tokenId); + /// @notice The named recipient is not the NFT's owner (ERC-721) nor the collection's owner / royalty receiver (ERC-1155). + error NotRecipient(address collection, uint256 tokenId, address recipient); + error FeeTooHigh(uint16 feeBps); + error InvalidFeeRecipient(address recipient); + + /// @notice Tip the NFT `tokenId` of `collection`: `recipient` is verified against the NFT, then paid `amount` of `token`. + function tip( + address collection, + NFTStandard standard, + uint256 tokenId, + address recipient, + address token, + uint256 amount + ) external; + /// @notice Tip an account the caller names. + function tipAccount(address token, address to, uint256 amount) external; + + /// @notice Whether `tip` would accept `recipient` for this NFT; reverts for a non-contract or an invalid token, like `tip`. + function isValidTipRecipient(address collection, NFTStandard standard, uint256 tokenId, address recipient) + external + view + returns (bool); + /// @notice Send everything this contract holds in `token` (accrued fees) to the fee recipient. Anyone may call. + function withdrawFees(address token) external; + + function feeBps() external view returns (uint16); + function feeRecipient() external view returns (address); +} diff --git a/src/tipping/Tipping.sol b/src/tipping/Tipping.sol new file mode 100644 index 00000000..86b3ac44 --- /dev/null +++ b/src/tipping/Tipping.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +import {ITipping} from "./ITipping.sol"; + +/// @dev Optional surfaces an ERC-1155 collection may expose; each is probed behind try/catch. +interface IERC1155SupplyLike { + function totalSupply(uint256 id) external view returns (uint256); +} + +interface IRoleLike { + function hasRole(bytes32 role, address account) external view returns (bool); +} + +interface IOwnableLike { + function owner() external view returns (address); +} + +/** + * @title Nodle Tipping v2 + * @author Nodle + * @notice Lets anyone tip an NFT post or an account with any ERC-20, on zkSync Era. + * + * @dev How a post tip works + * The caller passes the collection, its standard (ERC-721 or ERC-1155), the token id, the + * recipient, the ERC-20 and the amount. The contract does not decide who gets paid; it + * checks that the caller's recipient is legitimate for that NFT: + * - ERC-721: `recipient` must be `ownerOf(tokenId)`. + * - ERC-1155: the id must exist (non-zero tracked supply, when the collection exposes + * `totalSupply(id)`) and `recipient` must hold the collection's `OWNER_ROLE` (Nodle + * user collections grant it to their creator), or be its Ownable `owner()`, or be its + * ERC-2981 royalty receiver for the id. + * Nothing is enumerated from the NFT, only verified, so any collection works without a + * registry or a backend signature. The checks are `view` calls (STATICCALL) behind + * try/catch: a hostile collection can only make the tip revert or burn the caller's gas. + * Account tips (`tipAccount`) name their recipient with no check. + * + * Money + * Tokens move with `transferFrom(msg.sender, …)` only: an allowance granted to this + * contract can be spent solely by the approver's own calls. The optional protocol fee + * (at most 10 %, 0 at launch) stays in the contract and `withdrawFees(token)`, callable by + * anyone, sends the contract's whole balance of that token to the fee recipient. Fees are + * the only balance the contract ever holds; a token sent here by mistake is recovered the + * same way. Self-tips and zero amounts revert. + * + * Governance + * Immutable: no proxy, no upgrade, no pause. `DEFAULT_ADMIN_ROLE` (a multisig) can only + * change the fee and the fee recipient. Events `Tipped`, `AccountTipped`, `FeeUpdated`, + * `FeeRecipientUpdated` and `FeesWithdrawn` are the surface the Nodle feed indexer reads. + */ +contract Tipping is ITipping, AccessControl, ReentrancyGuard { + using SafeERC20 for IERC20; + + uint16 public constant MAX_FEE_BPS = 1000; + uint256 public constant BPS = 10_000; + /// @dev Role Nodle user collections grant to their creator (keccak256("OWNER_ROLE")). + bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE"); + + uint16 public feeBps; + address public feeRecipient; + + constructor(address admin) { + if (admin == address(0)) revert ZeroAddress(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + feeRecipient = admin; + emit FeeUpdated(0); + emit FeeRecipientUpdated(admin); + } + + // ───────── Tipping ───────── + + /// @inheritdoc ITipping + function tip( + address collection, + NFTStandard standard, + uint256 tokenId, + address recipient, + address token, + uint256 amount + ) external nonReentrant { + if (!isValidTipRecipient(collection, standard, tokenId, recipient)) { + revert NotRecipient(collection, tokenId, recipient); + } + uint256 fee = _transferTipWithFee(token, recipient, amount); + emit Tipped(msg.sender, collection, tokenId, token, recipient, amount, fee); + } + + /// @inheritdoc ITipping + function tipAccount(address token, address to, uint256 amount) external nonReentrant { + uint256 fee = _transferTipWithFee(token, to, amount); + emit AccountTipped(msg.sender, to, token, amount, fee); + } + + /// @inheritdoc ITipping + /// @dev ERC-721: `recipient` must be the current owner. ERC-1155: the id must exist (tracked + /// supply, when exposed, must be non-zero) and `recipient` must hold OWNER_ROLE, be the + /// Ownable owner, or be the ERC-2981 receiver for the id; the collection must declare + /// ERC-1155 support (ERC-165). Reverts for non-contracts and invalid tokens; returns false + /// for a wrong recipient or a wrong standard. + function isValidTipRecipient(address collection, NFTStandard standard, uint256 tokenId, address recipient) + public + view + returns (bool) + { + if (collection.code.length == 0) revert NotAContract(collection); + if (recipient == address(0)) return false; + if (standard == NFTStandard.ERC721) { + try IERC721(collection).ownerOf(tokenId) returns (address owner) { + return owner == recipient; + } catch { + revert InvalidToken(collection, tokenId); + } + } + // The ERC-1155 proofs (role, owner, royalty) say nothing about a token id, so they only + // apply to a collection that declares itself ERC-1155. Otherwise an ERC-721 with an + // OWNER_ROLE passed as "ERC-1155" would skip ownerOf and pay the creator for any id. + try IERC165(collection).supportsInterface(type(IERC1155).interfaceId) returns (bool is1155) { + if (!is1155) return false; + } catch { + return false; + } + try IERC1155SupplyLike(collection).totalSupply(tokenId) returns (uint256 supply) { + if (supply == 0) revert InvalidToken(collection, tokenId); + } catch { + // No supply extension: nothing to check here. + } + try IRoleLike(collection).hasRole(OWNER_ROLE, recipient) returns (bool held) { + if (held) return true; + } catch {} + try IOwnableLike(collection).owner() returns (address owner) { + if (owner == recipient) return true; + } catch {} + try IERC2981(collection).royaltyInfo(tokenId, BPS) returns (address receiver, uint256) { + if (receiver == recipient) return true; + } catch {} + return false; + } + + /// @dev Shared guards and transfers. Only ever pulls from msg.sender: the property the + /// immutability argument rests on. Returns the fee taken. + function _transferTipWithFee(address token, address to, uint256 amount) internal returns (uint256 fee) { + if (token == address(0) || to == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + if (to == msg.sender) revert SelfTip(); + fee = (amount * feeBps) / BPS; + IERC20(token).safeTransferFrom(msg.sender, to, amount - fee); + if (fee > 0) IERC20(token).safeTransferFrom(msg.sender, address(this), fee); + } + + /// @inheritdoc ITipping + function withdrawFees(address token) external nonReentrant { + if (token == address(0)) revert ZeroAddress(); + uint256 amount = IERC20(token).balanceOf(address(this)); + if (amount == 0) revert ZeroAmount(); + address to = feeRecipient; + IERC20(token).safeTransfer(to, amount); + emit FeesWithdrawn(token, to, amount); + } + + // ───────── Admin ───────── + + function setFee(uint16 bps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (bps > MAX_FEE_BPS) revert FeeTooHigh(bps); + feeBps = bps; + emit FeeUpdated(bps); + } + + function setFeeRecipient(address recipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (recipient == address(0)) revert ZeroAddress(); + // Withdrawing to itself would be a no-op: fees could never leave the contract. + if (recipient == address(this)) revert InvalidFeeRecipient(recipient); + feeRecipient = recipient; + emit FeeRecipientUpdated(recipient); + } +} diff --git a/test/tipping/Tipping.t.sol b/test/tipping/Tipping.t.sol new file mode 100644 index 00000000..39d2fdc2 --- /dev/null +++ b/test/tipping/Tipping.t.sol @@ -0,0 +1,583 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {ERC1155Supply} from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol"; +import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {Tipping} from "../../src/tipping/Tipping.sol"; +import {ITipping} from "../../src/tipping/ITipping.sol"; + +/// @dev Records every `transferFrom` so tests can prove the contract only ever pulls from the caller. +contract TokenMock is ERC20 { + address[] public senders; + uint256[] public amounts; + + constructor(string memory sym) ERC20(sym, sym) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transferFrom(address from, address to, uint256 value) public override returns (bool) { + senders.push(from); + amounts.push(value); + return super.transferFrom(from, to, value); + } + + function transfers() external view returns (uint256) { + return senders.length; + } +} + +/// @dev A Click-style post: plain ERC-721, transferable, no royalties. +contract Post721 is ERC721 { + constructor() ERC721("Click", "CLK") {} + + function mint(address to, uint256 id) external { + _mint(to, id); + } + + function burn(uint256 id) external { + _burn(id); + } +} + +/// @dev A factory-style edition: ERC-1155 with supply tracking and a default royalty receiver. +contract Edition1155 is ERC1155Supply, ERC2981 { + constructor() ERC1155("ipfs://x") {} + + function mint(address to, uint256 id, uint256 amount) external { + _mint(to, id, amount, ""); + } + + function burn(address from, uint256 id, uint256 amount) external { + _burn(from, id, amount); + } + + function setRoyalty(address receiver, uint96 bps) external { + if (receiver == address(0)) _deleteDefaultRoyalty(); + else _setDefaultRoyalty(receiver, bps); + } + + function supportsInterface(bytes4 id) public view override(ERC1155, ERC2981) returns (bool) { + return super.supportsInterface(id); + } +} + +/// @dev A Nodle-style edition: supply tracking, OWNER_ROLE held by the creator, no royalty. +contract NodleEdition1155 is ERC1155Supply, AccessControl { + bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE"); + + constructor(address owner_) ERC1155("ipfs://n") { + _grantRole(OWNER_ROLE, owner_); + } + + function mint(address to, uint256 id, uint256 amount) external { + _mint(to, id, amount, ""); + } + + function supportsInterface(bytes4 id) public view override(ERC1155, AccessControl) returns (bool) { + return super.supportsInterface(id); + } +} + +/// @dev A Nodle-style ERC-721 collection: OWNER_ROLE held by the creator (like UserCollection721). +contract NodleCollection721 is ERC721, AccessControl { + bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE"); + + constructor(address owner_) ERC721("Nodle721", "N721") { + _grantRole(OWNER_ROLE, owner_); + } + + function mint(address to, uint256 id) external { + _mint(to, id); + } + + function supportsInterface(bytes4 id) public view override(ERC721, AccessControl) returns (bool) { + return super.supportsInterface(id); + } +} + +/// @dev A third-party Ownable edition: no roles, no royalty, no supply tracking. +contract Ownable1155 is ERC1155, Ownable { + constructor(address owner_) ERC1155("ipfs://o") Ownable(owner_) {} + + function mint(address to, uint256 id, uint256 amount) external { + _mint(to, id, amount, ""); + } +} + +/// @dev An ERC-1155 that tracks no supply, pays no royalties and has no owner (nothing to verify against). +contract Bare1155 is ERC1155 { + constructor() ERC1155("ipfs://y") {} + + function mint(address to, uint256 id, uint256 amount) external { + _mint(to, id, amount, ""); + } +} + +/// @dev A hostile "collection": ownerOf tries to re-enter the tipping contract. +contract ReentrantCollection { + Tipping internal immutable tipping; + address internal immutable token; + + constructor(Tipping t, address tok) { + tipping = t; + token = tok; + } + + function ownerOf(uint256) external returns (address) { + tipping.tipAccount(token, address(0xBEEF), 1); + return address(this); + } +} + +contract TippingTest is Test { + TokenMock internal nodl; + TokenMock internal usdc; + Post721 internal post; + Edition1155 internal edition; + NodleEdition1155 internal nodleEdition; + Ownable1155 internal ownable; + Bare1155 internal bare; + Tipping internal tipping; + + address internal constant ADMIN = address(0xAD); + address internal constant ALICE = address(0xA1); + address internal constant BOB = address(0xB0); + address internal constant CREATOR = address(0xC4); + uint256 internal constant POST_ID = 7; + uint256 internal constant EDITION_ID = 0; + + function setUp() public { + nodl = new TokenMock("NODL"); + usdc = new TokenMock("USDC"); + post = new Post721(); + edition = new Edition1155(); + bare = new Bare1155(); + nodleEdition = new NodleEdition1155(CREATOR); + ownable = new Ownable1155(CREATOR); + tipping = new Tipping(ADMIN); + + post.mint(BOB, POST_ID); + edition.mint(ALICE, EDITION_ID, 100); + edition.setRoyalty(CREATOR, 500); + bare.mint(BOB, 1, 1); + nodleEdition.mint(ALICE, EDITION_ID, 10); + ownable.mint(ALICE, EDITION_ID, 10); + + nodl.mint(ALICE, 1_000e18); + usdc.mint(ALICE, 1_000e6); + vm.startPrank(ALICE); + nodl.approve(address(tipping), type(uint256).max); + usdc.approve(address(tipping), type(uint256).max); + vm.stopPrank(); + } + + function tipPost(address who, uint256 amount) internal { + address owner = post.ownerOf(POST_ID); // before the prank: an external call would consume it + vm.prank(who); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, owner, address(nodl), amount); + } + + function notRecipient(address collection, uint256 id, address who) internal pure returns (bytes memory) { + return abi.encodeWithSelector(ITipping.NotRecipient.selector, collection, id, who); + } + + // ───────── Constructor ───────── + + function test_constructor_state() public view { + assertEq(tipping.feeBps(), 0); + assertEq(tipping.feeRecipient(), ADMIN); + assertTrue(tipping.hasRole(tipping.DEFAULT_ADMIN_ROLE(), ADMIN)); + } + + function test_constructor_emitsInitialSettings() public { + vm.expectEmit(true, true, true, true); + emit ITipping.FeeUpdated(0); + vm.expectEmit(true, true, true, true); + emit ITipping.FeeRecipientUpdated(ADMIN); + new Tipping(ADMIN); + } + + function test_constructor_rejectsZeroAdmin() public { + vm.expectRevert(ITipping.ZeroAddress.selector); + new Tipping(address(0)); + } + + // ───────── ERC-721 posts ───────── + + function test_tip721_paysCurrentOwner() public { + vm.expectEmit(true, true, true, true); + emit ITipping.Tipped(ALICE, address(post), POST_ID, address(nodl), BOB, 5e18, 0); + tipPost(ALICE, 5e18); + assertEq(nodl.balanceOf(BOB), 5e18); + assertEq(nodl.balanceOf(ALICE), 995e18); + assertEq(nodl.transfers(), 1); + } + + function test_tip721_followsTransfers() public { + vm.prank(BOB); + post.transferFrom(BOB, CREATOR, POST_ID); + assertTrue(tipping.isValidTipRecipient(address(post), ITipping.NFTStandard.ERC721, POST_ID, CREATOR)); + assertFalse(tipping.isValidTipRecipient(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB)); + tipPost(ALICE, 1e18); + assertEq(nodl.balanceOf(CREATOR), 1e18); + assertEq(nodl.balanceOf(BOB), 0); + // The previous owner is no longer a valid recipient. + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(post), POST_ID, BOB)); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), 1e18); + } + + function test_tip721_neverMintedOrBurnedIsInvalid() public { + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(post), 99)); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, 99, BOB, address(nodl), 1e18); + + post.burn(POST_ID); + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(post), POST_ID)); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), 1e18); + } + + function test_tip721_ownerCannotTipOwnPost() public { + nodl.mint(BOB, 1e18); + vm.prank(BOB); + nodl.approve(address(tipping), 1e18); + vm.prank(BOB); + vm.expectRevert(ITipping.SelfTip.selector); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), 1e18); + } + + function test_tip_anyErc20() public { + vm.prank(ALICE); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(usdc), 3e6); + assertEq(usdc.balanceOf(BOB), 3e6); + } + + // ───────── ERC-1155 editions ───────── + + function test_tip1155_paysRoyaltyReceiver() public { + vm.expectEmit(true, true, true, true); + emit ITipping.Tipped(ALICE, address(edition), EDITION_ID, address(nodl), CREATOR, 2e18, 0); + vm.prank(ALICE); + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), 2e18); + assertEq(nodl.balanceOf(CREATOR), 2e18); + assertTrue(tipping.isValidTipRecipient(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR)); + assertFalse(tipping.isValidTipRecipient(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, ALICE)); + } + + function test_tip1155_zeroSupplyIsInvalid() public { + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(edition), 42)); + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, 42, CREATOR, address(nodl), 1e18); + + edition.burn(ALICE, EDITION_ID, 100); + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(edition), EDITION_ID)); + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), 1e18); + } + + function test_tip1155_royaltyReceiverIsTheOnlyProofOnThisEdition() public { + // Someone else than the royalty receiver is refused. + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(edition), EDITION_ID, BOB)); + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, BOB, address(nodl), 1e18); + // With the royalty removed nobody can be verified any more. + edition.setRoyalty(address(0), 0); + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(edition), EDITION_ID, CREATOR)); + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), 1e18); + // No supply extension, no roles, no owner, no ERC-2981: nothing to verify against. + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(bare), 1, BOB)); + tipping.tip(address(bare), ITipping.NFTStandard.ERC1155, 1, BOB, address(nodl), 1e18); + } + + function test_tip1155_nodleEditionPaysItsOwnerRoleHolder() public { + // Nodle collections: no royalty, but the creator holds OWNER_ROLE. + assertTrue( + tipping.isValidTipRecipient(address(nodleEdition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR) + ); + assertFalse(tipping.isValidTipRecipient(address(nodleEdition), ITipping.NFTStandard.ERC1155, EDITION_ID, ALICE)); + vm.expectEmit(true, true, true, true); + emit ITipping.Tipped(ALICE, address(nodleEdition), EDITION_ID, address(nodl), CREATOR, 3e18, 0); + vm.prank(ALICE); + tipping.tip(address(nodleEdition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), 3e18); + assertEq(nodl.balanceOf(CREATOR), 3e18); + // A holder of the edition is not its owner. + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(nodleEdition), EDITION_ID, ALICE)); + tipping.tip(address(nodleEdition), ITipping.NFTStandard.ERC1155, EDITION_ID, ALICE, address(nodl), 1e18); + // An id never minted on a supply-tracking edition is invalid. + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(nodleEdition), 5)); + tipping.tip(address(nodleEdition), ITipping.NFTStandard.ERC1155, 5, CREATOR, address(nodl), 1e18); + } + + function test_tip1155_ownableEditionPaysItsOwner() public { + assertTrue(tipping.isValidTipRecipient(address(ownable), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR)); + vm.prank(ALICE); + tipping.tip(address(ownable), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), 1e18); + assertEq(nodl.balanceOf(CREATOR), 1e18); + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(ownable), EDITION_ID, BOB)); + tipping.tip(address(ownable), ITipping.NFTStandard.ERC1155, EDITION_ID, BOB, address(nodl), 1e18); + } + + function test_tip_zeroRecipientIsNotARecipient() public { + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(post), POST_ID, address(0))); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, address(0), address(nodl), 1e18); + assertFalse( + tipping.isValidTipRecipient(address(nodleEdition), ITipping.NFTStandard.ERC1155, EDITION_ID, address(0)) + ); + } + + function test_tip1155_royaltyReceiverCannotTipItself() public { + nodl.mint(CREATOR, 1e18); + vm.startPrank(CREATOR); + nodl.approve(address(tipping), 1e18); + vm.expectRevert(ITipping.SelfTip.selector); + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), 1e18); + vm.stopPrank(); + } + + // ───────── Resolution guards ───────── + + function test_tip_erc721WithOwnerRolePassedAs1155IsRefused() public { + // Regression (Copilot review): a role-bearing ERC-721 declared as ERC-1155 must not skip + // ownerOf and pay the creator for a transferred, burned or never-minted id. + NodleCollection721 col = new NodleCollection721(CREATOR); + col.mint(BOB, 1); + assertFalse(tipping.isValidTipRecipient(address(col), ITipping.NFTStandard.ERC1155, 1, CREATOR)); + assertFalse(tipping.isValidTipRecipient(address(col), ITipping.NFTStandard.ERC1155, 99, CREATOR)); + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(col), 99, CREATOR)); + tipping.tip(address(col), ITipping.NFTStandard.ERC1155, 99, CREATOR, address(nodl), 1e18); + // Declared correctly it pays the owner and refuses the creator. + assertTrue(tipping.isValidTipRecipient(address(col), ITipping.NFTStandard.ERC721, 1, BOB)); + assertFalse(tipping.isValidTipRecipient(address(col), ITipping.NFTStandard.ERC721, 1, CREATOR)); + } + + function test_tip1155_requiresErc165() public { + // A contract with no supportsInterface at all (here: an ERC-20) declared as ERC-1155 is refused + // before any proof is consulted (the ERC-165 probe reverts → false). + assertFalse(tipping.isValidTipRecipient(address(usdc), ITipping.NFTStandard.ERC1155, 1, BOB)); + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(usdc), 1, BOB)); + tipping.tip(address(usdc), ITipping.NFTStandard.ERC1155, 1, BOB, address(nodl), 1e18); + } + + function test_tip1155_noProofMatchesIsFalse() public { + // Genuine ERC-1155, every proof consulted, none names BOB: role (none), owner (CREATOR), royalty (none). + assertFalse(tipping.isValidTipRecipient(address(ownable), ITipping.NFTStandard.ERC1155, EDITION_ID, BOB)); + // Genuine ERC-1155 with a royalty receiver that is not BOB. + assertFalse(tipping.isValidTipRecipient(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, BOB)); + } + + function test_tip_wrongStandardFailsToResolve() public { + // ERC-1155 path on a plain ERC-721: no totalSupply, no royaltyInfo. + vm.prank(ALICE); + vm.expectRevert(notRecipient(address(post), POST_ID, BOB)); + tipping.tip(address(post), ITipping.NFTStandard.ERC1155, POST_ID, BOB, address(nodl), 1e18); + // ERC-721 path on an ERC-1155: no ownerOf. + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(edition), EDITION_ID)); + tipping.tip(address(edition), ITipping.NFTStandard.ERC721, EDITION_ID, CREATOR, address(nodl), 1e18); + } + + function test_tip_collectionMustBeAContract() public { + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.NotAContract.selector, address(0xD00D))); + tipping.tip(address(0xD00D), ITipping.NFTStandard.ERC721, 1, BOB, address(nodl), 1e18); + vm.expectRevert(abi.encodeWithSelector(ITipping.NotAContract.selector, address(0))); + tipping.isValidTipRecipient(address(0), ITipping.NFTStandard.ERC721, 1, BOB); + } + + function test_tip_lookupIsStaticAndCannotReenter() public { + ReentrantCollection evil = new ReentrantCollection(tipping, address(nodl)); + // ownerOf is declared view on IERC721, so the call is a STATICCALL: the reentrant + // tipAccount inside it reverts, the try/catch turns that into InvalidToken. + vm.prank(ALICE); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidToken.selector, address(evil), 1)); + tipping.tip(address(evil), ITipping.NFTStandard.ERC721, 1, address(evil), address(nodl), 1e18); + assertEq(nodl.transfers(), 0); + } + + function test_tip_guards() public { + vm.startPrank(ALICE); + vm.expectRevert(ITipping.ZeroAddress.selector); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(0), 1e18); + vm.expectRevert(ITipping.ZeroAmount.selector); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), 0); + vm.stopPrank(); + } + + function test_tip_insufficientAllowanceOrBalanceReverts() public { + vm.prank(ALICE); + nodl.approve(address(tipping), 1e18); + vm.prank(ALICE); + vm.expectRevert(); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), 2e18); + vm.prank(ALICE); + nodl.approve(address(tipping), type(uint256).max); + vm.prank(ALICE); + vm.expectRevert(); + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), 1_001e18); + } + + // ───────── Account tips ───────── + + function test_tipAccount_paysNamedAddress() public { + vm.expectEmit(true, true, true, true); + emit ITipping.AccountTipped(ALICE, BOB, address(nodl), 4e18, 0); + vm.prank(ALICE); + tipping.tipAccount(address(nodl), BOB, 4e18); + assertEq(nodl.balanceOf(BOB), 4e18); + } + + function test_tipAccount_guards() public { + vm.startPrank(ALICE); + vm.expectRevert(ITipping.ZeroAddress.selector); + tipping.tipAccount(address(nodl), address(0), 1e18); + vm.expectRevert(ITipping.ZeroAddress.selector); + tipping.tipAccount(address(0), BOB, 1e18); + vm.expectRevert(ITipping.ZeroAmount.selector); + tipping.tipAccount(address(nodl), BOB, 0); + vm.expectRevert(ITipping.SelfTip.selector); + tipping.tipAccount(address(nodl), ALICE, 1e18); + vm.stopPrank(); + } + + // ───────── Fees ───────── + + function test_feeSplitAndCap() public { + vm.prank(ADMIN); + vm.expectEmit(true, true, true, true); + emit ITipping.FeeUpdated(250); + tipping.setFee(250); + + vm.expectEmit(true, true, true, true); + emit ITipping.Tipped(ALICE, address(post), POST_ID, address(nodl), BOB, 100e18, 2.5e18); + tipPost(ALICE, 100e18); + assertEq(nodl.balanceOf(BOB), 97.5e18); + // The fee accrues in the contract; the fee recipient is paid on withdraw, not per tip. + assertEq(nodl.balanceOf(address(tipping)), 2.5e18); + assertEq(nodl.balanceOf(ADMIN), 0); + assertEq(nodl.transfers(), 2); + + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(ITipping.FeeTooHigh.selector, uint16(1001))); + tipping.setFee(1001); + vm.prank(ADMIN); + tipping.setFee(1000); + assertEq(tipping.feeBps(), 1000); + } + + function test_withdrawFees_anyoneSendsAccruedToFeeRecipient() public { + vm.prank(ADMIN); + tipping.setFee(1000); + vm.prank(ADMIN); + tipping.setFeeRecipient(CREATOR); + tipPost(ALICE, 50e18); + vm.prank(ALICE); + tipping.tipAccount(address(nodl), BOB, 10e18); + assertEq(nodl.balanceOf(address(tipping)), 6e18); + + vm.expectEmit(true, true, true, true); + emit ITipping.FeesWithdrawn(address(nodl), CREATOR, 6e18); + vm.prank(BOB); // permissionless + tipping.withdrawFees(address(nodl)); + assertEq(nodl.balanceOf(CREATOR), 6e18); + assertEq(nodl.balanceOf(address(tipping)), 0); + + // Nothing accrued: revert rather than emit a zero withdrawal. + vm.expectRevert(ITipping.ZeroAmount.selector); + tipping.withdrawFees(address(nodl)); + vm.expectRevert(ITipping.ZeroAddress.selector); + tipping.withdrawFees(address(0)); + // Stray tokens sent directly are recoverable the same way. + usdc.mint(address(tipping), 7e6); + tipping.withdrawFees(address(usdc)); + assertEq(usdc.balanceOf(CREATOR), 7e6); + } + + function test_feeRoundsDownInFavourOfRecipient() public { + vm.prank(ADMIN); + tipping.setFee(1); + vm.prank(ALICE); + tipping.tipAccount(address(nodl), BOB, 9_999); + assertEq(nodl.balanceOf(BOB), 9_999); + assertEq(nodl.balanceOf(address(tipping)), 0); + assertEq(nodl.transfers(), 1); + } + + function test_feeRecipientCannotBeZeroOrSelf() public { + vm.startPrank(ADMIN); + vm.expectRevert(ITipping.ZeroAddress.selector); + tipping.setFeeRecipient(address(0)); + vm.expectRevert(abi.encodeWithSelector(ITipping.InvalidFeeRecipient.selector, address(tipping))); + tipping.setFeeRecipient(address(tipping)); + vm.expectEmit(true, true, true, true); + emit ITipping.FeeRecipientUpdated(BOB); + tipping.setFeeRecipient(BOB); + vm.stopPrank(); + assertEq(tipping.feeRecipient(), BOB); + } + + function test_adminOnly() public { + bytes memory denied = + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, ALICE, bytes32(0)); + vm.startPrank(ALICE); + vm.expectRevert(denied); + tipping.setFee(1); + vm.expectRevert(denied); + tipping.setFeeRecipient(BOB); + vm.stopPrank(); + } + + // ───────── Invariant ───────── + + /// @dev Whatever the path, token or fee, every transferFrom has `from == msg.sender`. + function testFuzz_transferFromOnlyFromCaller(address tipper, uint256 amount, uint16 bps, uint8 path) public { + vm.assume(tipper != address(0) && tipper != BOB && tipper != CREATOR && tipper != address(tipping)); + vm.assume(tipper.code.length == 0); + amount = bound(amount, 1, 1e30); + bps = uint16(bound(bps, 0, 1000)); + vm.prank(ADMIN); + tipping.setFee(bps); + nodl.mint(tipper, amount); + uint256 before = nodl.balanceOf(tipper); // ALICE already holds a balance from setUp + vm.prank(tipper); + nodl.approve(address(tipping), amount); + + vm.prank(tipper); + if (path % 3 == 0) { + tipping.tip(address(post), ITipping.NFTStandard.ERC721, POST_ID, BOB, address(nodl), amount); + } else if (path % 3 == 1) { + tipping.tip(address(edition), ITipping.NFTStandard.ERC1155, EDITION_ID, CREATOR, address(nodl), amount); + } else { + tipping.tipAccount(address(nodl), BOB, amount); + } + + uint256 n = nodl.transfers(); + assertTrue(n == 1 || n == 2); + uint256 total; + for (uint256 i = 0; i < n; i++) { + assertEq(nodl.senders(i), tipper); + total += nodl.amounts(i); + } + assertEq(total, amount); + assertEq(nodl.balanceOf(tipper), before - amount); + } +}