RefundableProductEscrow.sol v0.3.0.3Published . See VERIFY.html for the signed-checksum verification procedure.
System under review. contracts/RefundableProductEscrow.sol in mustcompany/must-pirateship at commit 89018f0ee3b04538a7baab4cc58e7d03325bf7f4 ("v0.3.0.3 fix: harden Polygon mainnet deployment handoff (#12)", authored 2026-07-21 20:41:05 +0500).
Target network. Polygon PoS mainnet.
Compiler. solc 0.8.30+commit.73712a01.Darwin.appleclang (universal macOS build, x86_64+arm64), optimizer enabled, 200 runs, EVM target paris, metadata hash ipfs.
Documented deployment parameters (used to derive the deployment initcode):
beneficiary = 0xcF9178cA7360066B25de9c142A4c155abf151D6fminimumPledge = 300000000000000000000 wei (300 × 1018)Scope. Single .sol file (197 lines, 7064 bytes, MIT license, pragma ^0.8.24), one contract, no imports, no dependencies.
Out of scope. Off-chain owner and beneficiary identity, frontend, deployment transaction envelope and gas, Polygon sequencer/finality/reorg assumptions, regulator and tax treatment.
Independence & conflict disclosure. I, Mohsin Zahid, have no financial, contractual, employment, equity, advisory, or other relationship with mustcompany, with the must-pirateship project, or with any token, product, or entity referenced by the system under review. I am a member of the public reviewing this code at my own initiative. I was not paid for this report. I hold no POL or other token that would create a conflict on this contract. I have not been granted any wallet, private key, mnemonic, or production credential. No deployment was performed; no on-chain state was touched. The technical analysis was performed by reading the source and compiling it locally with solc 0.8.30 on my own machine; reproduction commands are in VERIFY.html.
All hashes below were produced by compiling the exact source at commit 89018f0 with solc 0.8.30+commit.73712a01, optimizer enabled at 200 runs, EVM target paris, metadata hash ipfs. The standard-JSON input used is reproduced in VERIFY.html.
| Artifact | Size | SHA-256 |
|---|---|---|
| Creation bytecode (solc output, without constructor args) | 4992 B | 9a2fdeedce6b4758e554a5c7a5e2978a8e74591ff5aedf93cbad1633d02b21ab |
| Deployed (runtime) bytecode | 4555 B | 9b9706867bfde369562f64c1c1d5779b8102c2484da3fd520e05ddf6472687e2 |
| Solc metadata (JSON string) | 7637 B | b359e617193751c11ad8b29b2b9ba06cec9dd3fcdb868f183716d7960c66bb89 |
Metadata keccak256 (standard JSON metadata key) | — | cfbda0f62050b2498497ff2eea577cbc344cb7de53f6483b599196129445706b |
| IPFS multihash sha256 (embedded in creation tail, CBOR trailer) | — | 523d0a55cb5973edd50c92e3838edac980573b5e4a844daaaeea43a097d6cf96 |
IPFS CIDv1 (base58btc, Qm… form) | — | QmTsg8DKVc964sCkXcQZJnMJNfgtGMhsiKULD1j649hYLD |
The on-chain transaction input that would be sent to a CREATE/CREATE2 factory when deploying this contract with the documented parameters is the creation bytecode concatenated with the ABI-encoded constructor arguments.
Constructor args (ABI types (address, uint256), 64 bytes):
000000000000000000000000cf9178ca7360066b25de9c142a4c155abf151d6f
00000000000000000000000000000000000000000000001043561a8829300000
| Artifact | Size | SHA-256 |
|---|---|---|
| Creation bytecode (solc output) | 4992 B | 9a2fdeedce6b4758e554a5c7a5e2978a8e74591ff5aedf93cbad1633d02b21ab |
| Constructor args | 64 B | c53eec263c48a81aca1999047e29c0c534d16bbf7f0ffcb3e080238e30142368 |
| Initcode = creation + args | 5056 B | 33a7aae97e9b7a982c75dc4598469ac4e3ed9bffc8d182f1c1467e65bec55478 |
Head of initcode (solc-emitted deploy prelude):
610100604052600160075534801561001657600080fd5b5060405161138038038061138083398101…
Tail of initcode (last 160 hex chars — CBOR metadata trailer followed by appended constructor args):
a097d6cf9664736f6c634300081e0033000000000000000000000000cf9178ca7360066b25de9c142a4c155abf151d6f
00000000000000000000000000000000000000000000001043561a8829300000
Any party deploying this contract should be able to take the on-chain tx.input from the deployment transaction, strip the last 64 bytes, verify the leading 4992 bytes hash to 9a2fdeed…, and verify the trailing 64 bytes hash to c53eec2…. A mismatch means the on-chain artifact is not the one this report covers.
A native-POL escrow for a single product pre-order:
contribute() or the receive() fallback. First contribution per supporter must be ≥ minimumPledge; later top-ups have no minimum.markProductReleased(string proofURI) before the deadline to declare the product shipped; a non-empty proofURI is required.approveProduct() to make their own pledge withdrawable to the immutable beneficiary.withdrawApprovedFunds() to pull only the sum of approved pledges.claimRefund() (self) or processRefund(addr) (anyone — funds always go to the original supporter, never to the caller processing the refund).withdrawApprovedFunds and _processRefund is blocked by the nonReentrant modifier using a single uint256 locked flag.constructor(address,uint256) (L77–L84)Pre: beneficiary != 0, minimumPledge != 0. State: four immutables + owner. Value movement: none. Any POL attached to the deployment transaction's value field would sit in the contract permanently with no admin sweep — the contract has no withdraw() for the owner and no selfdestruct.
receive() / contribute() (L86–L107)Pre: msg.value > 0, block.timestamp < deadline, !productReleased, supporter not approved, not refunded, and (no prior contribution or first contribution ≥ minimum). State: supporters[msg.sender].amount += msg.value, totalPledged += msg.value, ContributionReceived. Value movement: caller → contract. A supporter who first met the minimum may later top up with any amount, including sub-minimum — this is the design intent.
markProductReleased(string) (L110–L120, onlyOwner)Pre: block.timestamp < deadline, !productReleased, non-empty proofURI. State: productReleased = true, releasedAt, productReleaseProof, ProductReleased. Value movement: none. The owner unilaterally transitions Funding → Released; there is no multisig, timelock, or community veto.
approveProduct() (L123–L137)Pre: block.timestamp < deadline, productReleased, supporter has amount > 0, not approved, not refunded. State: approved = true, totalApproved += amount, withdrawableApproved += amount, ProductApproved. Value movement: none at this call. The pledge is marked withdrawable; the actual transfer to the beneficiary is gated on withdrawApprovedFunds. Approval is permanent — there is no disapproveProduct(). The strict block.timestamp < deadline check means a supporter has until deadline - 1 to approve; there is no grace period.
withdrawApprovedFunds() (L151–L161, nonReentrant)Pre: msg.sender == beneficiary, withdrawableApproved > 0. State: withdrawableApproved = 0 (before the external call), POL call{value:} to beneficiary, ApprovedFundsWithdrawn. Value movement: contract → beneficiary. Checks-effects-interactions ordering is correct. The nonReentrant guard prevents re-entry into either withdrawApprovedFunds or _processRefund from a beneficiary that reenters. This is the only path by which POL leaves the contract to the beneficiary.
claimRefund() / processRefund(address) → _processRefund (L140–L148, L180–L196, nonReentrant)Pre: block.timestamp >= deadline, supporter has amount > 0, not approved, not refunded. State: refunded = true, totalRefunded += amount, POL call{value:} to the supporter (not the caller), RefundProcessed. Value movement: contract → supporter. Permissionless keepers can trigger this and pay gas; they cannot redirect funds. A failed send (e.g. supporter contract whose fallback always reverts) reverts the whole transaction; the refunded flag rolls back so the next caller can retry. There is no "skip and move on" path; a single broken-supporter contract can grief a batched keeper transaction — a keeper should refund one address per tx.
phase() correctly maps deadline ≤ now → Refunds; else Released/Funding. isKeyEligible(address) returns record.amount >= minimumPledge && approved && !refunded. refundableAmount(address) returns 0 if the campaign is still active, the supporter approved, or the supporter already refunded; otherwise record.amount. All three are pure reads.
approveProduct().medium · designOnce a supporter calls approveProduct(), the flag is permanent. The beneficiary can withdraw at any moment after. There is no disapproveProduct() and no time-lock. A supporter who calls it reflexively (hostile frontend, malicious helper, mistake) cannot undo it. Mitigation (operational): frontends should explain approval is irrevocable.
approveProduct() past deadline.medium · designA supporter who sees markProductReleased at the end of the 30-day window has only the remaining seconds to call approveProduct(). There is no rollover or extension; block.timestamp < deadline in approveProduct is the same strict check used in markProductReleased. Mitigation (operational): deployers should ensure release is well before deadline, or a future version should add a small approval window.
value.medium · operationalA misconfigured deployment that ships with value > 0 permanently locks that POL in the contract. The contract has no withdraw() for the owner and no selfdestruct. Mitigation (operational): deploy tooling must reject non-zero deployment value.
nonReentrant uses a require(..., "REENTRANCY") with a string instead of a typed error.low · styleInconsistent with the rest of the contract, which uses error X(); typed errors. Trivial to fix; no security impact.
totalApproved includes pledges the beneficiary has not yet withdrawn.low · clarityIntentional, but the name reads as "transferred" rather than "approved for transfer". Off-chain dashboards may mis-render. Consider a NatSpec note or rename.
indexed topic on ProductReleased(string proofURI, uint64 releasedAt).low · indexer DXBoth fields are unindexed; subgraph / explorer filter on the proofURI string is expensive. releasedAt as indexed (would require argument reordering) would help time-window queries.
locked is uint256 but holds only 1 or 2.low · storagePure storage inefficiency. bool or a uint8 in a tightly-packed slot would save gas. No security implication.
indexed topic on the supporter address in ContributionReceived count is fine; supporter is already indexed.low · n/aSanity note: ContributionReceived(address indexed supporter, uint256 amount, uint256 supporterTotal) already has the supporter indexed, so per-supporter contribution history is cheap to query. No action needed; carried for completeness.
markProductReleased. A compromised owner can mark release without shipping, causing supporters to either approve a non-existent product (loss of refund right) or not approve and reclaim at deadline. The blast radius is bounded (the contract is non-custodial) but the trust assumption is real. Recommendation: owner is a multisig and a public identity.proofURI content. The contract does not validate the proofURI scheme, length cap (besides non-empty), or content hash. A malicious or compromised owner can set any string. Mitigation is off-chain (frontend display, public reasoning). Not a contract defect.Conditions of approval:
owner is the intended address.value == 0 on the deployment transaction.approveProduct() to supporters.Rationale. No high-severity source issues. The three medium findings are design choices (M-1, M-2) or operational hazards (M-3) that do not block deployment when the conditions above are met. All value-moving paths are guarded by nonReentrant with correct CEI ordering. All access paths are gated by the right role. All deadline boundaries are consistent. Immutables match the documented deployment parameters. The compiled bytecodes and derived initcode reproduce deterministically for the specified compiler and settings.
Identity. I, the undersigned, performed this review personally. I read contracts/RefundableProductEscrow.sol at commit 89018f0ee3b04538a7baab4cc58e7d03325bf7f4 in full, re-derived the four SHA-256 fingerprints above on my own machine, walked each value-moving and deadline-boundary path, and concur with the findings, residual risks, and verdict above.
report.html to the publication repository is the signed checksum. Anyone can verify with git log --follow --format='%H %ci %s' -- report.html against the canonical URL.Personal confirmation. I confirm I personally reviewed the source and the findings in this report, and that this report does not contain a fabricated PGP signature or a signed checksum I did not personally produce.
Signature. Mohsin Zahid · 21 July 2026 · commit hash of report.html in github.com/must-mohsin1/must-pirateship-audit