| Item | State |
|---|---|
| Internal review | Completed 2026-09-30 on commit 45a7bc4, and extended the same day to the owner role. No critical, high or medium findings. |
| External audit | Not yet performed. Required before mainnet deployment. |
| Deployment | None |
Please report vulnerabilities privately through GitHub's "Report a vulnerability" form on this repository, not in public issues.
| Party | Trusted to | Cannot |
|---|---|---|
| Owner (Morcast's owner wallet) | Replace the settler and the treasury. Choose tokens and pause creation for new campaigns. Void funded campaigns. Recover stray tokens | Move a campaign's funds anywhere except back to its brand. Change the formula, the fee, the deadlines or the 90-day maximum. Change a settlement |
| Settler (Morcast's settler wallet) | Settle each campaign with the recognized total and payout tree of the published result dataset | Settle outside [Day 5, Day 10) or twice. Change the fee recipient or the refund recipient. Move escrowed funds in any other way |
| Brand | Nothing beyond its own campaign's parameters | Cancel after startAt. Withdraw before settlement or Day 10. Affect other campaigns |
| Creators and anyone else | Nothing | Claim anything but an existing, unclaimed leaf. Redirect a payout |
| Token issuers | Circle (USDC) can pause transfers and blacklist addresses | MOR on Base (MOROFT, not upgradeable) has no pause, blacklist or transfer fee |
Base's sequencer orders transactions and sets timestamps. Deadlines are whole days, so timestamp drift has no practical effect. If the chain is unavailable for the whole settlement window, the campaign falls back to the full refund.
- Code:
src/MorcastEscrow.sol,src/libraries/SettlementMath.sol,src/interfaces/IMorcastEscrow.sol. - Manual review: access control, the status machine, reentrancy and call ordering, arithmetic and rounding, Merkle proofs and leaf encoding, token behaviour, deadlines, isolation between campaigns, owner powers, and denial of service.
- Static analysis:
- Slither 0.11.6 (all detectors), Aderyn 0.6.8 and the Foundry linter
- Solidity 0.8.37 has no known compiler bugs
- no OpenZeppelin Contracts advisory affects the modules used (
Ownable2Step,SafeERC20,MerkleProof,Math,ReentrancyGuardTransient).
- Tests:
- unit tests with 100% line coverage, and fuzz tests
- invariant tests: 51,200 random calls per CI run, including every owner action
- Base mainnet fork tests with the real USDC and MOR
- the local end-to-end run
- the attack scenarios in
test/Security.t.sol.
| ID | Severity | Finding | Status |
|---|---|---|---|
| S-1 | Centralization | The owner and the settler are trusted | By design. Mitigated operationally |
| S-2 | Low | No upper bound on the campaign schedule | Fixed: 90-day maximum |
| S-3 | Low | The USDC issuer can freeze transfers | Accepted |
| S-4 | Informational | A wrong payout tree cannot be corrected | Mitigated off-chain |
| S-5 | Informational | Tokens sent directly to the escrow are locked | Fixed: recoverTokens |
| S-6 | Informational | Transient storage is required | Accepted |
| S-7 | Informational | Campaign IDs are assigned at execution | Documented for integrators |
| S-8 | Informational | Tool warnings | Acknowledged |
| S-9 | Informational | The owner can void a running campaign | By design. Governed by policy |
For every campaign inside its settlement window, a compromised or dishonest settler can choose any recognized total (up to spending the whole budget) and any Merkle root. That sends up to the creator pool, 80% of the budget, to wallets of its choice. The fee still goes to the treasury and the refund to the brand.
The owner can appoint the settler, so a compromised owner has the same reach. In addition, it can void funded campaigns (their budgets go back to the brands) and redirect future fee withdrawals. Neither role can take escrowed funds in any other way. This is the protocol's trust model, with Morcast as the single trusted verifier.
Mitigations:
- Keep the owner and the settler in separate wallets, with their keys on hardware signers.
- Settle only from a published dataset that passes
morcast-verify. - Monitor every
CampaignSettledevent against the published datasets, and every owner event (SettlerUpdated,TreasuryUpdated,CampaignTokenUpdated,CreationPausedUpdated,CampaignVoided,TokensRecovered, ownership transfers).
createCampaign accepted any startAt < endAt. Once startAt has passed, the brand cannot cancel, and an unsettled budget returns only from endAt + 10 days. A mistaken endAt, for example milliseconds instead of seconds, would have locked the budget for thousands of years.
Fixed. createCampaign rejects campaigns longer than MAX_CAMPAIGN_DURATION (90 days), so a mistaken endAt fails at creation. A lead time before startAt needs no limit, because the brand can cancel at any time before the start. The owner can also void a campaign created by mistake.
If Circle pauses USDC, every USDC campaign is blocked until the pause ends. A blacklisted creator, brand or treasury cannot receive its payout. A blacklisted escrow would freeze all USDC campaigns. There is no path to reassign a payout. MOR is not affected.
The contract checks proofs, not the contents of the tree. If the leaves add up to less than the pool, or pay an address that can never receive (the zero address or the escrow itself), the difference stays locked, because settlement is final.
Mitigations:
- The SDK refuses zero-address payouts.
- The verifier rejects creator wallets equal to the zero address or the escrow.
- The platform must verify the dataset before calling
settle.
Leaves that add up to more than the pool are harmless, because claims stop at the pool.
Tokens transferred to the escrow other than through createCampaign could not be withdrawn. They never affected any campaign's accounting.
Fixed. The escrow tracks totalOwed per token, and the owner's recoverTokens returns only the balance above it. Campaign funds cannot be recovered.
The reentrancy guard uses EIP-1153 transient storage. Deploy only to chains with the Cancun instruction set. Base supports it, and the fork tests run there.
Concurrent createCampaign transactions can change which ID a campaign receives. Off-chain systems must read the ID from the CampaignCreated event of the brand's transaction, not predict it.
| Tool | Warning | Assessment |
|---|---|---|
| Slither | timestamp |
Every deadline is a whole number of days. |
| Slither | naming-convention |
Constants use upper case, as the Foundry linter requires. |
| Slither | incorrect-equality (excess == 0 in recoverTokens) |
Extra tokens only make more tokens recoverable. The check cannot block anything or reach campaign funds. |
| Aderyn | Centralization risk | The owner role. See S-1. |
| Aderyn | Address state variable set without checks | setSettler accepts the zero address on purpose, because that disables settlement. |
| Aderyn | Uninitialized local variable | A loop counter that starts at zero (for (uint256 i; ...)). |
| Aderyn | PUSH0 opcode | Supported on Base. |
| Aderyn | Floating pragma | Only in the interface and the library, so integrators can import them. The contract is pinned to 0.8.37. |
The owner can void a funded campaign at any time, including after creators have posted their content. The budget goes back to the brand only, so nothing can be stolen, but creators lose the payout they were working toward. Voiding must follow Morcast's published policy, for example for a mistaken deposit, a cancellation agreed with the brand before creators start, or an emergency.
| Property | Evidence |
|---|---|
| Only the brand cancels or withdraws its funds. Only the settler settles. Campaign funds go only to leaf wallets, the treasury or the brand | Unit tests |
| Only the owner can call owner actions, and an ownership transfer must be accepted | Owner tests |
The owner cannot take campaign funds. Voiding pays only the brand, and recovery returns only tokens beyond totalOwed |
test_voidCampaign_returnsBudgetToBrand, test_recoverTokens_cannotTouchCampaignFunds, invariant suite |
| Pausing creation or disallowing a token never blocks existing campaigns | test_pause_blocksOnlyCampaignCreation, test_disallowedToken_blocksOnlyNewCampaigns |
A campaign never pays out more than its budget, since fee + pool + refund = budget and claims never exceed the pool |
Invariant suite. SettlementMath fuzz tests. Duplicate-leaf test |
totalOwed always equals the outstanding obligations of all campaigns, and the balance equals totalOwed plus stray tokens |
Invariant suite |
| At every moment exactly one of settlement and the full refund is possible | testFuzz_settlementAndRefundAreMutuallyExclusive |
| A proof is valid for one campaign only. Each leaf is paid once. Front-running a claim is harmless | Unit tests. test_frontRunClaimPaysCreatorOnce |
| A brand's approval cannot be spent by anyone else | test_brandApprovalCannotBeSpentByOthers |
| A token callback cannot re-enter the escrow, and a relayer can batch claims | Reentrancy test. test_relayerCanBatchClaimsInOneTransaction |
| A deposit must deliver exactly the budget | Fee-on-transfer test |
No campaign can last longer than 90 days, so a mistaken endAt cannot lock a budget |
test_createCampaign_rejectsEndAtInMilliseconds and the duration tests |
| The real USDC and MOR contracts behave as expected | Base fork tests |