paymaster will refund spentOnPubdata to user
criticalLines of code
Vulnerability details
Vulnerability details
A very important modification of this update is that the GAS spent by pubdata is collected at the final step of the transaction.
But if there is a paymaster, when executing paymaster.postTransaction(_maxRefundedGas)
_maxRefundedGas does not subtract the spentOnPubdata
bootloader.yul the code is as follow:
yulfunction refundCurrentL2Transaction( txDataOffset, transactionIndex, success, gasLeft, gasPrice, reservedGas, basePubdataSpent, gasPerPubdata ) -> finalRefund { setTxOrigin(BOOTLOADER_FORMAL_ADDR()) finalRefund := 0 let innerTxDataOffset := add(txDataOffset, 32) let paymaster := getPaymaster(innerTxDataOffset) let refundRecipient := 0 switch paymaster case 0 { // No paymaster means that the sender should receive the refund refundRecipient := getFrom(innerTxDataOffset) } default { refundRecipient := paymaster if gt(gasLeft, 0) { checkEnoughGas(gasLeft) let nearCallAbi := getNearCallABI(gasLeft) let gasBeforePostOp := gas() pop(ZKSYNC_NEAR_CALL_callPostOp( // Maximum number of gas that the postOp could spend nearCallAbi, paymaster, txDataOffset, success, // Since the paymaster will be refunded with reservedGas, // it should know about it @> safeAdd(gasLeft, reservedGas, "jkl"), basePubdataSpent, reservedGas, gasPerPubdata )) let gasSpentByPostOp := sub(gasBeforePostOp, gas()) gasLeft := saturatingSub(gasLeft, gasSpentByPostOp) } } // It was expected that before this point various `isNotEnoughGasForPubdata` methods would ensure that the user // has enough funds for pubdata. Now, we just subtract the leftovers from the user. @> let spentOnPubdata := getErgsSpentForPubdata( basePubdataSpent, gasPerPubdata ) let totalRefund := saturatingSub(add(reservedGas, gasLeft), spentOnPubdata) askOperatorForRefund( totalRefund, spentOnPubdata, gasPerPubdata ) let operatorProvidedRefund := getOperatorRefundForTx(transactionIndex) // If the operator provides the value that is lower than the one suggested for // the bootloader, we will use the one calculated by the bootloader. let refundInGas := max(operatorProvidedRefund, totalRefund) // The operator cannot refund more than the gasLimit for the transaction if gt(refundInGas, getGasLimit(innerTxDataOffset)) { assertionError("refundInGas > gasLimit") } if iszero(validateUint32(refundInGas)) { assertionError("refundInGas is not uint32") } let ethToRefund := safeMul( refundInGas, gasPrice, "fdf" ) directETHTransfer(ethToRefund, refundRecipient) finalRefund := refundInGas }
paymaster's _maxRefundedGas = gasLeft + reservedGas, without subtracting spentOnPubdata.
This way _maxRefundedGas will be much larger than the correct value
paymaster will refund the used spentOnPubdata to the user
Impact
paymaster will refund the spentOnPubdata already used by the user
Recommended Mitigation
difffunction refundCurrentL2Transaction( txDataOffset, transactionIndex, success, gasLeft, gasPrice, reservedGas, basePubdataSpent, gasPerPubdata ) -> finalRefund { setTxOrigin(BOOTLOADER_FORMAL_ADDR()) finalRefund := 0 let innerTxDataOffset := add(txDataOffset, 32) let paymaster := getPaymaster(innerTxDataOffset) let refundRecipient := 0 switch paymaster case 0 { // No paymaster means that the sender should receive the refund refundRecipient := getFrom(innerTxDataOffset) } default { refundRecipient := paymaster + let expectSpentOnPubdata := getErgsSpentForPubdata( + basePubdataSpent, + gasPerPubdata + ) if gt(gasLeft, 0) { checkEnoughGas(gasLeft) let nearCallAbi := getNearCallABI(gasLeft) let gasBeforePostOp := gas() pop(ZKSYNC_NEAR_CALL_callPostOp( // Maximum number of gas that the postOp could spend nearCallAbi, paymaster, txDataOffset, success, // Since the paymaster will be refunded with reservedGas, // it should know about it - safeAdd(gasLeft, reservedGas, "jkl"), + saturatingSub(add(reservedGas, gasLeft), expectSpentOnPubdata), basePubdataSpent, reservedGas, gasPerPubdata )) let gasSpentByPostOp := sub(gasBeforePostOp, gas()) gasLeft := saturatingSub(gasLeft, gasSpentByPostOp) } } // It was expected that before this point various `isNotEnoughGasForPubdata` methods would ensure that the user // has enough funds for pubdata. Now, we just subtract the leftovers from the user. let spentOnPubdata := getErgsSpentForPubdata( basePubdataSpent, gasPerPubdata ) let totalRefund := saturatingSub(add(reservedGas, gasLeft), spentOnPubdata) askOperatorForRefund( totalRefund, spentOnPubdata, gasPerPubdata ) let operatorProvidedRefund := getOperatorRefundForTx(transactionIndex) // If the operator provides the value that is lower than the one suggested for // the bootloader, we will use the one calculated by the bootloader. let refundInGas := max(operatorProvidedRefund, totalRefund) // The operator cannot refund more than the gasLimit for the transaction if gt(refundInGas, getGasLimit(innerTxDataOffset)) { assertionError("refundInGas > gasLimit") } if iszero(validateUint32(refundInGas)) { assertionError("refundInGas is not uint32") } let ethToRefund := safeMul( refundInGas, gasPrice, "fdf" ) directETHTransfer(ethToRefund, refundRecipient) finalRefund := refundInGas }
Assessed type
Context
