Platform: Code4rena
Start Date: 20/05/2022
Pot Size: $1,000,000 USDC
Total HM: 4
Participants: 59
Period: 14 days
Judge: leastwood
Id: 128
League: ETH
Rank: 18/59
Findings: 2
Award: $2,557.04
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Spearbit
Also found by: 0xsanson, Chom, IllIllI, OriDabush, Saw-mon_and_Natalie, broccoli, cccz, cmichel, csanuragjain, foobar, hack3r-0m, hickuphh3, hubble, hyh, ilan, kebabsec, mayo, oyc_109, peritoflores, rfa, scaraven, sces60107, shung, sorrynotsorry, tintin, twojoy, zkhorse, zzzitron
2058.3107 USDC - $2,058.31
My CS professor once said, code is like art. There are some pieces you come across that you can’t help but admire at their elegance and beauty. Seaport is one such piece of work that the optimizoooooorr gurus have blessed us with. Substantial portions of the codebase are written in assembly, with various gas optimization tricks sprinkled to ensure the code is as gas efficient as it can be.
The reference implementation is a great addition to be used to check against the optimized version. This practice should be adopted by future projects looking to have assembly code in their contracts.
Documentation wise, the inline comments were useful, especially those that explained the stack and memory positions of the variables. A video walkthrough would have greatly helped in getting people up to speed with the codebase, especially for one this complex. It would serve as an extremely useful educational material as well!
Some of the gas optimization tricks used aren’t immediately intuitive, so explaining those in a video walkthrough or via inline comments would have been appreciated. An example of this is the SignatureVerification
contract, where a couple of checks were omitted.
Overall, Seaport sets the gold standard of how to write a contract with extensive assembly code. Providing sufficient supporting material to explain what its intended behaviour is paramount in ensuring the code is behaving as intended.
The name is 13 bytes long, so it fills bytes 64-76, not to 77. It would be great to further describe the neat optimization trick used to store both the length and name via a single mstore
in the comments too! Under-appreciated sorcery going on here =)
name fills bytes 64-77
→ name fills bytes 64-76
The hash region is 66 bytes, not 65:
// Hash the relevant region (65 bytes).
→ // Hash the relevant region (66 bytes).
The zero remainder check has been performed in L105 due to a proposed gas optimization using mulmod
. The comment // Divide and check for remainder.
and reference implementation is therefore outdated.
AmountDeriver.sol
// Perform the division without zero check. Note that denominator cannot be zero. assembly { newValue := div(valueTimesNumerator, denominator) }
ReferenceAmountDeriver.sol
// insert this code block after L88 bool exact = mulmod(newValue, numerator, denominator) == 0; if (!exact) { revert InexactFraction(); }
s
is not checkedThe valid range for s
: 0 < s < secp256k1n Ă· 2 + 1
check has been omitted from the SignatureVerification
contract. It would be great to explain why this is the case (presumably as a gas optimization).
The impact of omitting the check is that “it nevertheless creates a UI inconvenience as an attacker can cause the transaction that gets confirmed in a block to have a different hash from the transaction that any user sends, interfering with user interfaces that use transaction hashes as tracking IDs”, which we assume is an acceptable tradeoff in favour of reduced gas usage.
https://ethereum.stackexchange.com/questions/55245/why-is-s-in-transaction-signature-limited-to-n-21
v
is not checked in EIP-2098 signaturesv
is typically checked to be either 27 or 28 regardless of signature length, as seen in OZ’s ECDSA library.
It’s not immediately intuitive the validity range of v
isn’t checked: it’s taken from the highest bit, which means it’s either a 0 or 1, then incremented by 27, thus ensuring that v
is definitely valid. It would be great to add this explanation as an inline comment.
ReferenceAssertions._assertValidBasicOrderParameterOffsets()
comment descriptionThe correct values for the Additional recipients arr offset
and Signature offset
should be 0x240
and 0x260 + (recipients.length * 0x40)
respectively.
* 2. Additional recipients arr offset == 0x240 * 3. Signature offset == 0x260 + (recipients.length * 0x40)
#0 - HardlyDifficult
2022-06-26T17:23:54Z
The intro is 🔥
I believe all this feedback is just a request for better documentation. Some of the reports appear to identify incorrect comments and others are asking for more detail. I agree it would be helpful for these to be addressed.
🌟 Selected for report: Dravee
Also found by: 0x1f8b, 0x29A, 0xalpharush, Chom, Czar102, Hawkeye, IllIllI, MaratCerby, MiloTruck, NoamYakov, OriDabush, RoiEvenHaim, Spearbit, Tadashi, TerrierLover, TomJ, asutorufos, cccz, cmichel, csanuragjain, defsec, delfin454000, djxploit, ellahi, foobar, gzeon, hake, hickuphh3, ignacio, ilan, joestakey, kaden, mayo, ming, oyc_109, peritoflores, rfa, sach1r0, sashik_eth, shung, sirhashalot, twojoy, zer0dot, zkhorse
498.7302 USDC - $498.73
The calculation is of the form (P.S. I hope GH markdown shows this nicely with their recent meth expression support) $a^2 - b^2$, which can be simplified to $(a+b) * (a-b)$. This will save 2 gas because 1 MUL
opcode is changed to a ADD
opcode.
mul( add(returnDataWords, msizeWords), sub(returnDataWords, msizeWords) ),
A further optimization can be made to save the result of sub(returnDataWords, msizeWords)
because it is performed twice.
The mul
and div
opcodes consume 5 gas while the shl
and shr
opcodes consume 3. Thus, any multiplication and division by powers of 2 can be switched to shl
and shr
respectively. Notably, OneWord = 0x20 = 32
is a power of 2, and there are many instances where multiplication and division is done with OneWord
.
Admittedly, this will come at the cost of readability. For instance,
mul(itemIndex, OneWord)
becomes
// OneWordBits = log2(0x20) = 5 shl(OneWordBits, itemIndex)
which isn’t as intuitive and readable.
The difference between the prefix increment / decrement and postfix increment / decrement expressions lie in the return value of the expression. The prefix expression (++i
/ --i
) returns the updated value after it's incremented. The postfix expression (i++
/ --i
) returns the original value.
It is generally cheaper to use the prefix expression whenever possible.
Since maximumFulfilled
isn’t used subsequently, it would be good to use the prefix increment. Interestingly, attempts to do the same for totalFilteredExecutions
yielded worse results, where it is cheapest to do totalFilteredExecutions += 1
.
--maximumFulfilled;
#0 - HardlyDifficult
2022-06-26T16:18:13Z
These suggestions should provide small savings and seem worth considering.