Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7a73cd9
feat(tipping): immutable Tipping contract with on-chain recipient res…
Douglasacost Oct 22, 2026
0e8d8bd
fix(tipping): zero-amount guard, single registration, fee-recipient s…
Douglasacost Oct 22, 2026
f2354a9
test(tipping): full suite (31 tests, 97% line coverage), local only
Douglasacost Oct 27, 2026
18889a8
feat(tipping): fork test, deploy and seed scripts, EraVM gas check, c…
Douglasacost Oct 27, 2026
79662b7
feat(tipping): agnostic signature-authorized tipping (any ERC-20, any…
Douglasacost Oct 27, 2026
9f6438c
test(tipping): rename mock field for spell check
Douglasacost Oct 27, 2026
6d7416b
Merge branch 'main' into feat/tipping
Douglasacost Oct 27, 2026
9a9bfa7
ops(tipping): zksync deploy wrapper that moves the L1-only files asid…
Douglasacost Oct 28, 2026
f97b7d4
ops(verify): inline Foundry remappings into imports; the explorer now…
Douglasacost Oct 28, 2026
4fd9816
feat(tipping): drop Pausable (review decision 2026-10-28)
Douglasacost Oct 29, 2026
f1a2649
feat(tipping): v2 resolves the recipient on-chain; no backend signature
Douglasacost Oct 29, 2026
9850026
test(tipping): rename for the spell check
Douglasacost Oct 29, 2026
c8aa7a9
feat(tipping): tip() takes the recipient and verifies it against the NFT
Douglasacost Oct 29, 2026
e56bee7
feat(tipping): fees accrue in the contract; withdrawFees(token) sends…
Douglasacost Oct 29, 2026
f3399d6
docs(tipping): contract title and description for v2
Douglasacost Oct 29, 2026
779c63a
refactor(tipping): review renames: NFTStandard, isValidTipRecipient p…
Douglasacost Oct 29, 2026
73a6f93
refactor(tipping): _pay → _transferTipWithFee; fuzz asserts the balan…
Douglasacost Oct 29, 2026
4eabf23
fix(tipping): review fixes from Copilot
Douglasacost Oct 29, 2026
0a78683
test(tipping): cover the ERC-165 refusal and the no-proof fallthrough…
Douglasacost Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
58 changes: 58 additions & 0 deletions ops/deploy_tipping_zksync.sh
Original file line number Diff line number Diff line change
@@ -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 <testnet|mainnet> [--broadcast]
#
# Without --broadcast the script simulates only. The broadcast JSON lands in
# broadcast/DeployTippingZkSync.s.sol/<chainId>/run-latest.json; verify afterwards with
# python3 ops/verify_zksync_contracts.py --broadcast <that file>.
Comment on lines +12 to +13

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed: Tipping added to CONTRACT_SOURCE_MAP and DeployTippingZkSync.s.sol to BROADCAST_CONTRACT_SEQUENCE.

set -euo pipefail
cd "$(dirname "$0")/.."

NETWORK="${1:-}"; BROADCAST="${2:-}"
case "$BROADCAST" in
""|--broadcast) ;;
*) echo "usage: $0 <testnet|mainnet> [--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 <testnet|mainnet> [--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}
42 changes: 41 additions & 1 deletion ops/verify_zksync_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions script/DeployTippingZkSync.s.sol
Original file line number Diff line number Diff line change
@@ -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 <testnet|mainnet> [--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`).");
}
}
68 changes: 68 additions & 0 deletions src/tipping/ITipping.sol
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading