Skip to content

Commit 6571c3c

Browse files
mergify[bot]srdtrk
andauthored
imp(contracts/ics20): add more balance validation to permit2 transfers (backport #954) (#987)
Co-authored-by: srdtrk <59252793+srdtrk@users.noreply.github.com> Co-authored-by: srdtrk <srdtrk@hotmail.com>
1 parent 00e0332 commit 6571c3c

7 files changed

Lines changed: 95 additions & 28 deletions

File tree

.github/actions/e2e-setup/action.yml

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,43 +18,28 @@ runs:
1818
check-latest: true
1919
cache-dependency-path: e2e/interchaintestv8/go.sum
2020

21-
- name: Cache Relayer and Operator
22-
id: cache-relayer
23-
uses: actions/cache@v4
24-
with:
25-
path: |
26-
~/.cargo/bin/relayer
27-
~/.cargo/bin/operator
28-
./target/elf-compilation/riscv32im-succinct-zkvm-elf/release/*
29-
key: ${{ runner.os }}-relayer-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'packages/**', 'programs/**', 'abi/**') }}
30-
3121
- name: Install SP1 toolchain
32-
if: (steps.cache-relayer.outputs.cache-hit != 'true')
3322
shell: bash
3423
run: |
3524
curl -L https://sp1.succinct.xyz | bash
36-
~/.sp1/bin/sp1up --token ${{ inputs.github_token }}
25+
~/.sp1/bin/sp1up --token ${{ inputs.github_token }} -v v5.2.4
3726
~/.sp1/bin/cargo-prove prove --version
3827
3928
- name: Install operator
40-
if: steps.cache-relayer.outputs.cache-hit != 'true'
4129
uses: actions-rs/cargo@v1
4230
with:
4331
command: install
4432
args: --bin operator --path programs/operator --locked
4533

4634
- name: Install relayer
47-
if: steps.cache-relayer.outputs.cache-hit != 'true'
4835
uses: actions-rs/cargo@v1
4936
with:
5037
command: install
5138
args: --bin relayer --path programs/relayer --locked
5239

5340
- name: Install just
54-
if: (steps.cache-relayer.outputs.cache-hit != 'true')
5541
uses: extractions/setup-just@v2
5642
- name: Build SP1 Programs
57-
if: (steps.cache-relayer.outputs.cache-hit != 'true')
5843
shell: bash
5944
run: just build-sp1-programs
6045

.github/actions/foundry-setup/action.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ runs:
44
using: composite
55
steps:
66
- name: Set up Rust
7-
uses: dtolnay/rust-toolchain@stable
7+
uses: dtolnay/rust-toolchain@1.87.0
88
with:
99
components: rustfmt, clippy
1010
- name: Install Foundry
1111
uses: foundry-rs/foundry-toolchain@v1
12+
with:
13+
version: v1.1.0
1214
- name: Install Bun
1315
uses: oven-sh/setup-bun@v2
1416
- name: Install the Node.js dependencies

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
steps:
1212
- uses: actions/checkout@v4
1313
- name: Set up Rust
14-
uses: dtolnay/rust-toolchain@stable
14+
uses: dtolnay/rust-toolchain@1.87.0
1515
- name: Install SP1 toolchain
1616
shell: bash
1717
run: |

.github/workflows/rust.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282
steps:
8383
- uses: actions/checkout@v4
8484
- name: Set up Rust
85-
uses: dtolnay/rust-toolchain@stable
85+
uses: dtolnay/rust-toolchain@1.87.0
8686
with:
8787
components: rustfmt, clippy
8888
targets: wasm32-unknown-unknown

contracts/ICS20Transfer.sol

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,7 @@ contract ICS20Transfer is
177177
);
178178
// transfer the tokens to us with permit
179179
IEscrow escrow = _getOrCreateEscrow(msg_.sourceClient);
180-
_getPermit2().permitTransferFrom(
181-
permit,
182-
ISignatureTransfer.SignatureTransferDetails({ to: address(escrow), requestedAmount: msg_.amount }),
183-
_msgSender(),
184-
signature
185-
);
180+
_transferFromWithPermit2(_msgSender(), address(escrow), msg_.denom, msg_.amount, permit, signature);
186181
escrow.recvCallback(msg_.denom, _msgSender(), msg_.amount);
187182

188183
return _sendTransferFromEscrowWithSender(msg_, address(escrow), _msgSender());
@@ -435,6 +430,50 @@ contract ICS20Transfer is
435430
);
436431
}
437432

433+
/// @notice Transfer tokens from sender to receiver using permit2
434+
/// @param sender The sender of the tokens
435+
/// @param receiver The receiver of the tokens
436+
/// @param tokenContract The address of the token contract
437+
/// @param amount The amount of tokens to transfer
438+
/// @param permit The permit data
439+
/// @param signature The signature of the permit data
440+
// NOTE: Balance reentrancy here is intentionally allowed, as we are checking the balance before and after
441+
// slither-disable-next-line reentrancy-balance
442+
function _transferFromWithPermit2(
443+
address sender,
444+
address receiver,
445+
address tokenContract,
446+
uint256 amount,
447+
ISignatureTransfer.PermitTransferFrom calldata permit,
448+
bytes calldata signature
449+
)
450+
private
451+
{
452+
// we snapshot current balance of this token
453+
uint256 ourStartingBalance = IERC20(tokenContract).balanceOf(receiver);
454+
455+
_getPermit2().permitTransferFrom(
456+
permit,
457+
ISignatureTransfer.SignatureTransferDetails({ to: receiver, requestedAmount: amount }),
458+
sender,
459+
signature
460+
);
461+
462+
// check what this particular ERC20 implementation actually gave us, since it doesn't
463+
// have to be at all related to the _amount
464+
uint256 actualEndingBalance = IERC20(tokenContract).balanceOf(receiver);
465+
466+
uint256 expectedEndingBalance = ourStartingBalance + amount;
467+
// a very strange ERC20 may trigger this condition, if we didn't have this we would
468+
// underflow, so it's mostly just an error message printer
469+
// NOTE: This is not a security check, but rather a sanity check.
470+
// slither-disable-next-line incorrect-equality
471+
require(
472+
actualEndingBalance > ourStartingBalance && actualEndingBalance == expectedEndingBalance,
473+
ICS20UnexpectedERC20Balance(expectedEndingBalance, actualEndingBalance)
474+
);
475+
}
476+
438477
/// @notice Finds a contract in the foreign mapping, or creates a new IBCERC20 contract
439478
/// @notice This function will never return address(0)
440479
/// @param fullDenomPath The full path denom to find or create the contract for (which will be the name for the

test/solidity-ibc/ICS20TransferTest.t.sol

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { IICS26Router } from "../../contracts/interfaces/IICS26Router.sol";
1515
import { IIBCUUPSUpgradeable } from "../../contracts/interfaces/IIBCUUPSUpgradeable.sol";
1616

1717
import { ICS20Transfer } from "../../contracts/ICS20Transfer.sol";
18-
import { TestERC20, MalfunctioningERC20 } from "./mocks/TestERC20.sol";
18+
import { TestERC20, MalfunctioningERC20, FeeOnTransferERC20 } from "./mocks/TestERC20.sol";
1919
import { ICS20Lib } from "../../contracts/utils/ICS20Lib.sol";
2020
import { ICS24Host } from "../../contracts/utils/ICS24Host.sol";
2121
import { Strings } from "@openzeppelin-contracts/utils/Strings.sol";
@@ -196,9 +196,30 @@ contract ICS20TransferTest is Test, DeployPermit2, PermitSignature {
196196
vm.prank(sender);
197197
ics20Transfer.sendTransferWithPermit2(_getTestSendTransferMsg(), permit, invalidSignature);
198198

199-
// prove that it works with a valid signature
199+
// ===== Case: ERC20 token with fee on transfer, where the balance after transfer is less than expected =====
200+
IICS20TransferMsgs.SendTransferMsg memory msgSendTransfer = _getTestSendTransferMsg();
201+
202+
FeeOnTransferERC20 feeOnTransferERC20 = new FeeOnTransferERC20(); // 1 unit fee on every transfer
203+
204+
msgSendTransfer.denom = address(feeOnTransferERC20);
205+
206+
ISignatureTransfer.PermitTransferFrom memory feePermit = ISignatureTransfer.PermitTransferFrom({
207+
permitted: ISignatureTransfer.TokenPermissions({ token: address(feeOnTransferERC20), amount: defaultAmount }),
208+
nonce: 2,
209+
deadline: block.timestamp + 100
210+
});
211+
bytes memory feeSignature =
212+
this.getPermitTransferSignature(feePermit, senderKey, address(ics20Transfer), permit2.DOMAIN_SEPARATOR());
213+
214+
feeOnTransferERC20.mint(sender, defaultAmount);
200215
vm.prank(sender);
201-
ics20Transfer.sendTransferWithPermit2(_getTestSendTransferMsg(), permit, signature);
216+
feeOnTransferERC20.approve(address(permit2), defaultAmount);
217+
218+
vm.expectRevert(
219+
abi.encodeWithSelector(IICS20Errors.ICS20UnexpectedERC20Balance.selector, defaultAmount, defaultAmount - 1)
220+
);
221+
vm.prank(sender);
222+
ics20Transfer.sendTransferWithPermit2(msgSendTransfer, feePermit, feeSignature);
202223
}
203224

204225
function test_success_sendTransferWithSender() public {

test/solidity-ibc/mocks/TestERC20.sol

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,23 @@ contract TestCustomERC20 is ERC20, IMintableAndBurnable {
6363
_burn(from, amount);
6464
}
6565
}
66+
67+
// Test contract to deploy ERC20 with fee on transfer, it takes a fee of 1 unit on every transfer and burns it, so the
68+
// recipient receives amount - 1
69+
contract FeeOnTransferERC20 is ERC20 {
70+
constructor() ERC20("Fee Token", "FEE") { }
71+
72+
function mint(address to, uint256 amount) external {
73+
_mint(to, amount);
74+
}
75+
76+
function _update(address from, address to, uint256 amount) internal override {
77+
if (from != address(0) && to != address(0)) {
78+
uint256 fee = 1;
79+
super._update(from, address(0), fee); // burn fee
80+
super._update(from, to, amount - fee); // deliver remainder
81+
} else {
82+
super._update(from, to, amount);
83+
}
84+
}
85+
}

0 commit comments

Comments
 (0)