From 6ea78be9746a33f7a2ee5fd1549120e5bfb9e82e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:48:52 +0000 Subject: [PATCH] Add set-market-fee-recipient, and SET_MARKET_FEE_RECIPIENT to run it A market with no fee recipient pays its owner, and every market the bootstrap creates is owned by the deployer. This points a market's fee elsewhere: ids from the registry, markets owned by another signer skipped, the value read back after the write. Market ids are required. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DPGQ6yZrEK3E9QwXesRojq --- .../helpers/tasks/set-market-fee-recipient.ts | 156 ++++++++++++++++++ packages/contracts/scripts/deploy-chain.sh | 43 +++++ 2 files changed, 199 insertions(+) create mode 100644 packages/contracts/helpers/tasks/set-market-fee-recipient.ts diff --git a/packages/contracts/helpers/tasks/set-market-fee-recipient.ts b/packages/contracts/helpers/tasks/set-market-fee-recipient.ts new file mode 100644 index 000000000..351e88b34 --- /dev/null +++ b/packages/contracts/helpers/tasks/set-market-fee-recipient.ts @@ -0,0 +1,156 @@ +import { ContractTransactionResponse, getAddress, ZeroAddress } from 'ethers' +import { task, types } from 'hardhat/config' +import { HardhatRuntimeEnvironment } from 'hardhat/types' + +/** + * Sets where a market's fee goes. + * + * TellerV2 pays the marketplace fee out of every loan it funds, to + * `MarketRegistry.getMarketFeeRecipient(marketId)`. A market created without + * one set - which is every market `bootstrap-markets` creates - falls back to + * the market owner, i.e. the deployer. This points it somewhere else. + * + * It governs loans funded from now on. A fee already paid stays where it went. + * + * Same shape as `set-market-payment-default`: ids come off the registry, not + * the bootstrap receipt, so it runs on any chain; a market owned by another + * address is reported and skipped rather than attempted and reverted; and the + * value is read back after the write, because `getMarketFeeRecipient` falls + * back to the owner when unset and a receipt alone proves nothing. + * + * yarn hh set-market-fee-recipient --network robinhood \ + * --recipient 0x... --markets 3 --dry-run true + */ + +interface MarketRegistryLike { + marketCount: () => Promise + getMarketOwner: (marketId: bigint) => Promise + getMarketFeeRecipient: (marketId: bigint) => Promise + getMarketplaceFee: (marketId: bigint) => Promise + setMarketFeeRecipient: ( + marketId: bigint, + recipient: string + ) => Promise +} + +/** "1,2, 3" -> [1n, 2n, 3n], rejecting anything that is not a market id. */ +const parseMarketIds = (raw: string): bigint[] => { + const ids = raw + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + if (!/^\d+$/.test(part)) { + throw new Error(`"${part}" is not a market id. Expected e.g. 1,2,3,4`) + } + return BigInt(part) + }) + + return [...new Set(ids)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) +} + +task( + 'set-market-fee-recipient', + "Sets the address a market's marketplace fee is paid to" +) + .addParam('recipient', 'Address to receive the market fee', undefined, types.string) + .addParam( + 'markets', + 'Comma-separated market ids. Required: a fee recipient is never swept across every market by default.', + undefined, + types.string + ) + .addOptionalParam( + 'dryRun', + 'Print what would be written without sending transactions', + false, + types.boolean + ) + .setAction(async (args, hre: HardhatRuntimeEnvironment): Promise => { + const { network, ethers } = hre + + // getAddress throws on a malformed address and on a wrong EIP-55 checksum, + // which is the point: this is where the money goes. + const recipient = getAddress(String(args.recipient).trim()) + if (recipient === ZeroAddress) { + throw new Error( + '--recipient is the zero address. MarketRegistry reads that as "unset" and pays the owner.' + ) + } + + const marketIds = parseMarketIds(args.markets as string) + if (marketIds.length === 0) { + throw new Error('--markets is empty. Name the market ids to change.') + } + + const registryContract = await hre.contracts.get('MarketRegistry') + const registry = registryContract as unknown as MarketRegistryLike + const registryAddress = await registryContract.getAddress() + + const [signer] = await ethers.getSigners() + const sender = await signer.getAddress() + const count = await registry.marketCount() + + console.log(`network ${network.name} (${network.config.chainId})`) + console.log(`registry ${registryAddress}`) + console.log(`signer ${sender}`) + console.log(`recipient ${recipient}`) + console.log(`markets ${marketIds.join(', ')} of ${count} on the registry`) + + const outOfRange = marketIds.filter((id) => id > count || id === 0n) + if (outOfRange.length > 0) { + throw new Error( + `Market id(s) ${outOfRange.join(', ')} do not exist on ${ + network.name + }: the registry has ${count} market(s), numbered 1..${count}.` + ) + } + + let written = 0 + let skipped = 0 + + for (const marketId of marketIds) { + const owner = await registry.getMarketOwner(marketId) + const existing = await registry.getMarketFeeRecipient(marketId) + const fee = await registry.getMarketplaceFee(marketId) + + console.log(`\n market ${marketId}`) + console.log(` owner ${owner}`) + console.log(` fee ${fee} bps`) + console.log(` current ${existing}`) + console.log(` new ${recipient}`) + + if (owner.toLowerCase() !== sender.toLowerCase()) { + console.log(' owned by another address - skipping') + skipped++ + continue + } + + if (existing.toLowerCase() === recipient.toLowerCase()) { + console.log(' unchanged - skipping') + skipped++ + continue + } + + if (args.dryRun) continue + + const tx = await registry.setMarketFeeRecipient(marketId, recipient) + const rcpt = await tx.wait() + console.log(` -> ${rcpt?.hash ?? tx.hash}`) + + const after = await registry.getMarketFeeRecipient(marketId) + console.log(` confirmed ${after}`) + if (after.toLowerCase() !== recipient.toLowerCase()) { + throw new Error( + `Market ${marketId} still pays ${after} after the write. Expected ${recipient}.` + ) + } + written++ + } + + console.log( + `\ndone: ${written} market(s) written, ${skipped} skipped${ + args.dryRun ? ' (dry run: nothing sent)' : '' + }` + ) + }) diff --git a/packages/contracts/scripts/deploy-chain.sh b/packages/contracts/scripts/deploy-chain.sh index 25f60a1d1..ec28383d3 100755 --- a/packages/contracts/scripts/deploy-chain.sh +++ b/packages/contracts/scripts/deploy-chain.sh @@ -179,6 +179,16 @@ # PAYMENT_DEFAULT_DRY_RUN=true # with SET_PAYMENT_DEFAULT, print before/after and # send nothing. +# SET_MARKET_FEE_RECIPIENT=true +# point markets' marketplace fee at a new address. +# Needs MARKET_FEE_RECIPIENT and MARKET_FEE_MARKETS. +# MARKET_FEE_RECIPIENT=
+# where the fee goes from now on. +# MARKET_FEE_MARKETS= +# comma-separated market ids. Required: never swept +# across every market by default. +# MARKET_FEE_DRY_RUN=true with SET_MARKET_FEE_RECIPIENT, print before/after +# and send nothing. # SWAP_VIA_LIFI=true swap one ERC-20 for another from the deployer, # routed by LI.FI, and stop. Needs SWAP_FROM, SWAP_TO # and SWAP_AMOUNT. For funding a pool whose principal @@ -748,6 +758,38 @@ if [ "${SET_PAYMENT_DEFAULT:-}" = "true" ]; then exit 0 fi +# Point a market's marketplace fee somewhere other than its owner. +# +# A market with no fee recipient pays its owner, and every market the bootstrap +# creates is owned by the deployer - so without this the fee on every loan lands +# in a burner key. Market ids are required rather than defaulting to the whole +# registry: redirecting fees is a decision about specific markets. +if [ "${SET_MARKET_FEE_RECIPIENT:-}" = "true" ]; then + [ -d "deployments/$NETWORK" ] || fail \ + "SET_MARKET_FEE_RECIPIENT needs deployments/$NETWORK in this checkout." + [ -n "${MARKET_FEE_RECIPIENT:-}" ] || fail \ + "SET_MARKET_FEE_RECIPIENT is set but MARKET_FEE_RECIPIENT is not." + [ -n "${MARKET_FEE_MARKETS:-}" ] || fail \ + "SET_MARKET_FEE_RECIPIENT is set but MARKET_FEE_MARKETS is not. Name the markets." + [ -n "${DEPLOYER_MNEMONIC:-}" ] || fail "DEPLOYER_MNEMONIC is not set." + printf '%s' "$DEPLOYER_MNEMONIC" > mnemonic.secret + chmod 600 mnemonic.secret + trap 'rm -f mnemonic.secret' EXIT + + if [ "${MARKET_FEE_DRY_RUN:-}" = "true" ]; then + log "Set market fee recipient on $NETWORK markets ${MARKET_FEE_MARKETS} to ${MARKET_FEE_RECIPIENT} (dry run — nothing will be sent)" + yarn hh set-market-fee-recipient --network "$NETWORK" \ + --recipient "$MARKET_FEE_RECIPIENT" --markets "$MARKET_FEE_MARKETS" --dry-run true + else + log "Set market fee recipient on $NETWORK markets ${MARKET_FEE_MARKETS} to ${MARKET_FEE_RECIPIENT}" + yarn hh set-market-fee-recipient --network "$NETWORK" \ + --recipient "$MARKET_FEE_RECIPIENT" --markets "$MARKET_FEE_MARKETS" + fi + + log "Done — $NETWORK (market fee recipient)" + exit 0 +fi + # Make a Uniswap V3 pool's TWAP readable. # # A pool priced off a TWAP needs its oracle pool to hold observations spanning @@ -1168,6 +1210,7 @@ if [ "${DEPLOY_PROTOCOL:-}" != "true" ]; then BOOTSTRAP_MARKETS=true create this chain's markets and lender pools SET_PRICE_CAPS=true cap existing pools at their current oracle price + SET_MARKET_FEE_RECIPIENT=true send a market's fee to a new address RUN_TAGS= run named deploy tags against a deployed chain VERIFY_ONLY=true verify already-deployed contracts REDEEM_POOL=true take the deployer's own deposit back out of a pool