Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion evm/src/apps/IntentGatewayV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,14 @@ contract IntentGatewayV2 is IntrinsicIntents, ExtrinsicIntents, ReentrancyGuardT
*/
function placeOrder(Order memory order, bytes32 graffiti) public payable nonReentrant {
if (order.inputs.length == 0) revert InvalidInput();
// Inputs and outputs pair 1:1 by index; reject mismatched orders that could never be filled.
if (order.inputs.length != order.output.assets.length) revert InvalidInput();

// Reject duplicate output tokens
// Reject duplicate output tokens
uint256 outputsLen_ = order.output.assets.length;
for (uint256 i; i < outputsLen_;) {
// A zero-amount output would strand its paired input escrow
if (order.output.assets[i].amount == 0) revert InvalidInput();
bytes32 token = order.output.assets[i].token;
assembly ("memory-safe") {
if tload(token) {
Expand Down
244 changes: 196 additions & 48 deletions evm/src/apps/intentsv2/ExtrinsicIntents.sol

Large diffs are not rendered by default.

75 changes: 67 additions & 8 deletions evm/src/apps/intentsv2/IntentsBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ abstract contract IntentsBase is EIP712 {
bytes32 constant FILLED_SLOT_BIG_ENDIAN_BYTES =
hex"0000000000000000000000000000000000000000000000000000000000000002";

/**
* @dev Big-endian encoding of storage slot 11 (the `_partialFills` mapping slot).
* Used to construct storage proof keys for cross-chain partial-fill cancel verification.
* Asserted against the compiled storage layout in the test suite to catch layout drift.
*/
bytes32 constant PARTIAL_FILLS_SLOT_BIG_ENDIAN_BYTES =
hex"000000000000000000000000000000000000000000000000000000000000000b";

/**
* @dev Discriminator for cross-chain request types dispatched via Hyperbridge.
* Encoded as the first byte of the request body in onAccept.
Expand Down Expand Up @@ -91,7 +99,13 @@ abstract contract IntentsBase is EIP712 {
/**
* @dev Upgrade the gateway implementation behind its ERC-1967 proxy.
*/
UpgradeContract
UpgradeContract,
/**
* @dev Release a proportional slice of escrowed tokens to the solver after a
* cross-chain partial fill, without finalizing the order. The completing fill
* uses `RedeemEscrow` (which finalizes and forwards accumulated fees).
*/
RedeemEscrowPartial
}

/**
Expand Down Expand Up @@ -243,11 +257,13 @@ abstract contract IntentsBase is EIP712 {
event PartialFill(bytes32 indexed commitment, address filler, TokenInfo[] outputs, TokenInfo[] inputs);

/**
* @dev Emitted when escrowed tokens are released to the solver after a successful fill.
* @dev Emitted when escrowed tokens are released to the solver after a successful (full or
* partial) fill. For cross-chain partial fills this is the only source-chain signal of release.
* @param commitment The order commitment hash.
* @param solver The recipient of the released escrow.
* @param tokens The tokens and amounts released.
*/
event EscrowReleased(bytes32 indexed commitment, TokenInfo[] tokens);
event EscrowReleased(bytes32 indexed commitment, address solver, TokenInfo[] tokens);

/**
* @dev Emitted when escrowed tokens are refunded to the original user after cancellation.
Expand Down Expand Up @@ -335,6 +351,46 @@ abstract contract IntentsBase is EIP712 {
return abi.encodePacked(keccak256(abi.encodePacked(commitment, FILLED_SLOT_BIG_ENDIAN_BYTES)));
}

/**
* @dev Computes the storage slot hash for `_partialFills[commitment][token]` on a remote
* chain. `_partialFills` is a nested mapping at slot 12, so the key is derived as
* keccak256(token . keccak256(commitment . 12)) — the standard Solidity nested-mapping layout.
* Used to construct GET storage-proof keys for cross-chain partial-fill cancel verification.
* @param commitment The order commitment hash.
* @param token The output token (bytes32-encoded address) whose fill progress is being proven.
* @return The ABI-encoded storage slot hash for the nested mapping entry.
*/
function _calculatePartialFillSlotHash(bytes32 commitment, bytes32 token) internal pure returns (bytes memory) {
bytes32 innerSlot = keccak256(abi.encodePacked(commitment, PARTIAL_FILLS_SLOT_BIG_ENDIAN_BYTES));
return abi.encodePacked(keccak256(abi.encodePacked(token, innerSlot)));
}

/**
* @dev Computes the cumulative escrow released for an input token given how much of its
* paired output has been filled. Defined as a single monotonic function so that the sum of
* per-fill release deltas exactly equals `escrowTotal` once the output is fully filled, with
* all integer-division rounding dust deterministically landing in the completing fill.
*
* Released(filled) = filled >= totalRequired ? escrowTotal : escrowTotal * filled / totalRequired
*
* This same function is used on the destination chain to size each `RedeemEscrow(Partial)`
* message and on the source chain to size cancel refunds, guaranteeing that
* (sum of redeems) + (cancel refund) == escrowTotal regardless of message arrival order.
*
* @param escrowTotal The full escrowed input amount for this token (order.inputs[i].amount).
* @param filled The cumulative amount of the paired output filled so far.
* @param totalRequired The total output amount required (order.output.assets[i].amount).
* @return The cumulative escrow that should have been released to solvers at this fill level.
*/
function _cumulativeReleased(uint256 escrowTotal, uint256 filled, uint256 totalRequired)
internal
pure
returns (uint256)
{
if (totalRequired == 0 || filled >= totalRequired) return escrowTotal;
return (escrowTotal * filled) / totalRequired;
}

/**
* @dev Releases escrowed tokens to a beneficiary. Iterates over the withdrawal request's
* token list, decrements the escrow balance for each, and transfers tokens out.
Expand Down Expand Up @@ -372,18 +428,21 @@ abstract contract IntentsBase is EIP712 {
}
}

// Fees and the filled-marker are only settled on finalization; the release/refund event is
// emitted for every withdrawal (including non-finalizing partial redeems and cancel refunds)
// so escrow movement is always observable.
if (finalize) {
uint256 fees = _orders[body.commitment][TRANSACTION_FEES];
if (fees > 0) {
delete _orders[body.commitment][TRANSACTION_FEES];
IERC20(IDispatcher(host()).feeToken()).safeTransfer(beneficiary, fees);
}
}

if (isRefund) {
emit EscrowRefunded({commitment: body.commitment, tokens: body.tokens});
} else {
emit EscrowReleased({commitment: body.commitment, tokens: body.tokens});
}
if (isRefund) {
emit EscrowRefunded({commitment: body.commitment, tokens: body.tokens});
} else {
emit EscrowReleased({commitment: body.commitment, solver: beneficiary, tokens: body.tokens});
}
}

Expand Down
3 changes: 3 additions & 0 deletions evm/src/apps/intentsv2/IntrinsicIntents.sol
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ abstract contract IntrinsicIntents is IntentsBase {
uint256 remaining = totalRequired - alreadyFilled;
if (remaining == 0 || solverAmount == 0) {
if (solverAmount == 0 && remaining > 0) isFullyFilled = false;
// Record the real tokens (with zero amounts) so emitted events carry token identity.
escrowedInputs[i] = TokenInfo({token: order.inputs[i].token, amount: 0});
outputFills[i] = TokenInfo({token: outputToken, amount: 0});
continue;
}
uint256 fillAmount;
Expand Down
28 changes: 9 additions & 19 deletions evm/tests/foundry/IntentGatewayV2SameChainTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest {
PaymentInfo output
);
event OrderFilled(bytes32 indexed commitment, address indexed filler, TokenInfo[] outputs, TokenInfo[] inputs);
event EscrowReleased(bytes32 indexed commitment, TokenInfo[] tokens);
event EscrowReleased(bytes32 indexed commitment, address solver, TokenInfo[] tokens);
event EscrowRefunded(bytes32 indexed commitment, TokenInfo[] tokens);
event DustCollected(address indexed token, uint256 amount);

Expand Down Expand Up @@ -847,7 +847,8 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest {
}

function testSameChainSwap_MismatchedLengths_ShouldRevert() public {
// 2 inputs, 1 output should revert with InvalidInput
// 2 inputs, 1 output is rejected at placeOrder: the 1:1 input/output pairing is required,
// so a mismatched order is unfillable and must never be escrowed.
TokenInfo[] memory inputs = new TokenInfo[](2);
inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6});
inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 500 * 1e18});
Expand All @@ -874,21 +875,8 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest {
vm.startPrank(user);
usdc.approve(address(intentGateway), 1000 * 1e6);
dai.approve(address(intentGateway), 500 * 1e18);
intentGateway.placeOrder(order, bytes32(0));
vm.stopPrank();

order.user = bytes32(uint256(uint160(user)));
order.source = host.host();
order.nonce = 0;

vm.startPrank(solver);
TokenInfo[] memory solverOutputs = new TokenInfo[](1);
solverOutputs[0] = TokenInfo({token: bytes32(0), amount: 1 ether});

vm.expectRevert(IntentsBase.InvalidInput.selector);
intentGateway.fillOrder{value: 1 ether}(
order, FillOptions({relayerFee: 0, nativeDispatchFee: 0, outputs: solverOutputs})
);
intentGateway.placeOrder(order, bytes32(0));
vm.stopPrank();
}

Expand Down Expand Up @@ -1931,14 +1919,15 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest {
/// @notice Placing an order with duplicate input tokens must revert.
/// Regression test for: same-chain partial fills over-release repeated input escrow.
function testRevert_PlaceOrder_DuplicateInputTokens() public {
// Two input legs both using USDC — this previously merged into one escrow bucket
// Two input legs both using USDC — this previously merged into one escrow bucket.
// Output tokens are distinct so only the input duplicate can trigger the revert.
TokenInfo[] memory inputs = new TokenInfo[](2);
inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1200 * 1e6});
inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6});

TokenInfo[] memory outputAssets = new TokenInfo[](2);
outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 500 * 1e18});
outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 1000 * 1e18});
outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 1000 * 1e6});

PaymentInfo memory output =
PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""});
Expand Down Expand Up @@ -1976,13 +1965,14 @@ contract IntentGatewayV2SameChainTest is MainnetForkBaseTest {
});
gatewayWithFees.initialize(intentParams, new bytes[](0));

// Output tokens are distinct so only the input duplicate can trigger the revert.
TokenInfo[] memory inputs = new TokenInfo[](2);
inputs[0] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 600 * 1e6});
inputs[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6});

TokenInfo[] memory outputAssets = new TokenInfo[](2);
outputAssets[0] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 300 * 1e18});
outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(dai)))), amount: 400 * 1e18});
outputAssets[1] = TokenInfo({token: bytes32(uint256(uint160(address(usdc)))), amount: 400 * 1e6});

PaymentInfo memory output =
PaymentInfo({beneficiary: bytes32(uint256(uint160(user))), assets: outputAssets, call: ""});
Expand Down
Loading
Loading