#38342 [SC-Medium] Interchanging `offchainTokenData` between two valid messages
Submitted on Dec 31st 2024 at 13:14:22 UTC by @security for Audit Comp | Lombard
Report ID: #38342
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/lombard-finance/evm-smart-contracts/blob/main/contracts/bridge/adapters/TokenPool.sol
Impacts:
Protocol insolvency
Temporary freezing of funds for at least 30 days
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol (not lower than $1K))
Description
Brief/Intro
A vulnerability exists in the bridge message processing flow where offchainTokenData
is not strictly tied to the corresponding bridged message. This allows a malicious actor to interchange offchainTokenData
between two valid messages (e.g., using offchainTokenData
from message1 for message2), leading to unintended behaviour in the token release or minting process.
Vulnerability Details
During the bridging, the hash of required payload is attached to the transferred data. https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L44
On the destination chain, the OffRamp
contract in Chainlink handles message delivery by invoking either the manuallyExecute
or execute
function. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L274 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L331
This triggers a series of internal calls to validate and process the message. The full flow of message delivery on the destination is as follows:
OffRamp::manuallyExecute/execute ==> OffRamp::_batchExecute ==> OffRamp::_executeSingleReport ==> OffRamp::_trialExecute ==> OffRamp::executeSingleMessage ==> OffRamp::_releaseOrMintTokens ==> OffRamp::_releaseOrMintSingleToken ==> TokenPool::releaseOrMint ==> CLAdapter::initiateWithdrawal ==> Bridge::receivePayload/authNotary/withdraw
https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L274 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L331 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L345 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L367 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L540 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L562 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L744 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L637 https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L57 https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/CLAdapter.sol#L200 https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/Bridge.sol#L178 https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/Bridge.sol#L220 https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/Bridge.sol#L263
Both of these functions manuallyExecute
or execute
will end in verification phase to be sure that the bridged message is valid: https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L389-L424
The important thing is that the verification is done on report.messages
, while the report
has also another important piece of data offchainTokenData
. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L389-L424
struct ExecutionReport {
uint64 sourceChainSelector; // Source chain selector for which the report is submitted.
Any2EVMRampMessage[] messages;
// Contains a bytes array for each message, each inner bytes array contains bytes per transferred token.
bytes[][] offchainTokenData;
bytes32[] proofs;
uint256 proofFlagBits;
}
https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/libraries/Internal.sol#L68 https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L368
This data offchainTokenData
carries off-chain data to process the release or mint in the TokenPool
. As you see, when releaseOrMint
is called in the TokenPool
, the offchainTokenData
is forwarded as a field in the struct ReleaseOrMintInV1
. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L664-L683
function releaseOrMint(
Pool.ReleaseOrMintInV1 calldata releaseOrMintIn
) external virtual override returns (Pool.ReleaseOrMintOutV1 memory) {
_validateReleaseOrMint(releaseOrMintIn);
uint64 amount = adapter.initiateWithdrawal(
releaseOrMintIn.remoteChainSelector,
releaseOrMintIn.offchainTokenData
);
emit Minted(msg.sender, releaseOrMintIn.receiver, uint256(amount));
return Pool.ReleaseOrMintOutV1({destinationAmount: uint256(amount)});
}
https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L57
struct ReleaseOrMintInV1 {
bytes originalSender; // The original sender of the tx on the source chain.
uint64 remoteChainSelector; // ─╮ The chain ID of the source chain.
address receiver; // ───────────╯ The recipient of the tokens on the destination chain.
uint256 amount; // The amount of tokens to release or mint, denominated in the source token's decimals.
address localToken; // The address on this chain of the token to release or mint.
/// @dev WARNING: sourcePoolAddress should be checked prior to any processing of funds. Make sure it matches the
/// expected pool address for the given remoteChainSelector.
bytes sourcePoolAddress; // The address of the source pool, abi encoded in the case of EVM chains.
bytes sourcePoolData; // The data received from the source pool to process the release or mint.
/// @dev WARNING: offchainTokenData is untrusted data.
bytes offchainTokenData; // The offchain data to process the release or mint.
}
https://github.com/smartcontractkit/chainlink/blob/498b0b8579ad52a8c394fe3cbf55d3a86a8e29a0/contracts/src/v0.8/ccip/libraries/Pool.sol#L48
Please note the comment: "offchainTokenData is untrusted data", showing that this data is not verified through Chainlink verification. So, it is the responsibility of the receiver protocol to validate it.
Its validation is done in the adapter through help of authority notarization by calling authNotary
. https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L62
function initiateWithdrawal(
uint64 remoteSelector,
bytes calldata offChainData
) external onlyTokenPool returns (uint64) {
(bytes memory payload, bytes memory proof) = abi.decode(
offChainData,
(bytes, bytes)
);
_receive(getChain[remoteSelector], payload);
bridge.authNotary(payload, proof);
return bridge.withdraw(payload);
}
https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/CLAdapter.sol#L200-L212
Issue
The issue is that the offchainTokenData
is not enforced to be related to the bridged message. In other words, suppose two valid messages are bridged and two corresponding offchainTokenData
are generated and validated by the authority. On the destination chain, if offchainTokenData
related to the first message is attached to the second message, and offchainTokenData
related to the second message is attached to the first message, both of these messages will be delivered and validated successfully. The key issue is that when the first message is processed on the destination chain, the offchainTokenData
associated with the second message is forwarded to the TokenPool
and subsequently processed on the Bridge. As a result, processing the first message ends up minting LBTC associated with the second message, while processing the second message mints LBTC associated with the first message.
Please note that, the action of swapping offchainTokenData
can be done easily by calling OffRamp::manuallyExecute
. In other words, one can call this function with the following parameters:
struct ExecutionReport {
uint64 sourceChainSelector;
Any2EVMRampMessage[] messages; // the first message data
bytes[][] offchainTokenData; // the data related to the second message
bytes32[] proofs; // valid proof related to the first message
uint256 proofFlagBits;
}
So that, the first message would be validated on Chainlink with provided proof. So, the status of the first message would be set as successful on Chainlink.
_setExecutionState(sourceChainSelector, message.header.sequenceNumber, Internal.MessageExecutionState.IN_PROGRESS);
(Internal.MessageExecutionState newState, bytes memory returnData) =
_trialExecute(message, offchainTokenData, tokenGasOverrides);
_setExecutionState(sourceChainSelector, message.header.sequenceNumber, newState);
https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L499
But, since offchainTokenData
associated the second message is forwarded to TokenPool
, the intended amount of LBTC will be minted to the receiver address set in the offchainTokenData
associated with the second message.
It means that swapping offchainTokenData
does not change the intended amount sent to the receiver, but it changes the protocol procedure significantly in handling the messages, impacting on the user’s intended actions.
For better understand, please consider the following example:
Suppose Alice calls brdige::deposit
to bridge 1 LBTC to chainX. Bob calls brdige::deposit
to bridge 100 LBTC to chainX. The bridge is using CLAdapter
to handle the bridging mechanism.
Alice deposits 1 LBTC to be bridged
Bob deposits 100 LBTC to be bridged
The payload generated for Alice and Bob would be:
Alice's payload:
bytes memory payloadAlice = abi.encodeWithSelector(
Actions.DEPOSIT_BRIDGE_ACTION,
bytes32(block.chainid),
bytes32(uint256(uint160(address(this)))),
chainX,
config.bridgeContract,
Alice,
1 LBTC,
$.crossChainOperationsNonce++
);
Bob's payload:
bytes memory payloadBob = abi.encodeWithSelector(
Actions.DEPOSIT_BRIDGE_ACTION,
bytes32(block.chainid),
bytes32(uint256(uint160(address(this)))),
chainX,
config.bridgeContract,
Bob,
100 LBTC,
$.crossChainOperationsNonce++
);
On chainX, Alice calls the function OffRamp::manuallyExecute
with the following parameters:
reports: [ ExecutionReport{ sourceChainSelector: // source chain messages: // the message related to Bob offchainTokenData: // the valid off-chain data related to Alice's message proofs: // the valid proof for Bob's message so that Chainlink can verify that Bob's message is delivered and processed securely proofFlagBits: // related to the provided proof for the Bob's message } ]
gasLimitOverrides
: // enough gas for executing the message delivery
Then, in the function OffRamp::_executeSingleReport
, the Bob's message will be verified with the provided proof, so that Chainlink ensures that the bridging is done securely. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L424
Then, the function TokenPool::releaseOrMint
is called with the forwarded offchainTokenData
related to Alice's message. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L676 https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L64
Since the provided offchainTokenData
is valid, it will pass all the checks during the calls to functions Bridge::receivePayload
, Bridge::authNotary
, and Bridge::withdraw
. Finally, 1 LBTC would be minted to Alice on chainX, and Alice's message would be flagged as withdrawn
, so that it can not be retired on chainX. https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/Bridge.sol#L312-L314
On OffRamp
, since the execution was successful, it sets the status of Bob's message as successful, so that it can not be later retried. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L499
The final status would be:
Alice receives her 1 LBTC on chainX successfully.
offchainTokenData
associated with the Alice's message is consumed on theBridge
.Alice's message is not consumed on
OffRamp
.offchainTokenData
associated with the Bob's message is not consumed on theBridge
.Bob's message is consumed on
OffRamp
.Bob has not received his 100 LBTC.
Bob does not lose his 100 LBTC, but if OffRamp::manuallyExecute
or OffRamp::execute
are invoked with Bob's message and offchainTokenData
associated with Bob's message as parameters, its execution would be skipped with the event SkippedAlreadyExecutedMessage
as Bob's proof and message is already consumed in Chainlink by Alice. https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/offRamp/OffRamp.sol#L437-L448
The root cause of this issue is that the offchainTokenData
is not enforced to be associated with the bridged message. The following check is missing:
function releaseOrMint(
Pool.ReleaseOrMintInV1 calldata releaseOrMintIn
) external virtual override returns (Pool.ReleaseOrMintOutV1 memory) {
_validateReleaseOrMint(releaseOrMintIn);
uint64 amount = adapter.initiateWithdrawal(
releaseOrMintIn.remoteChainSelector,
releaseOrMintIn.offchainTokenData,
+ releaseOrMintIn.sourcePoolData
);
emit Minted(msg.sender, releaseOrMintIn.receiver, uint256(amount));
return Pool.ReleaseOrMintOutV1({destinationAmount: uint256(amount)});
}
https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L57
function initiateWithdrawal(
uint64 remoteSelector,
bytes calldata offChainData,
+ bytes calldata sourcePoolData
) external onlyTokenPool returns (uint64) {
(bytes memory payload, bytes memory proof) = abi.decode(
offChainData,
(bytes, bytes)
);
+ require(keccak256(sourcePoolData) == keccak256(abi.encode(sha256(payload))), "offchain data is not related to the bridged message");
_receive(getChain[remoteSelector], payload);
bridge.authNotary(payload, proof);
return bridge.withdraw(payload);
}
https://github.com/lombard-finance/evm-smart-contracts/blob/edd557006050ee5b847fa1cc67c1c4e19079437e/contracts/bridge/adapters/TokenPool.sol#L57
Impact Details
Alice has consumed Bob's proof and message in Chainlink while supplying offchainTokenData
associated with her own message. As a result, Alice successfully receives her intended amount on the destination chain. However, Bob is now unable to use his own proof and message in Chainlink, as they have already been consumed by Alice. To recover his amounts, Bob would need to use Alice's proof and message in Chainlink while providing offchainTokenData
associated with his own message. This attack disrupts the intended functionality, particularly if Bob's transaction involved using CCIP to transfer tokens and interact with a contract on the destination chain (calling ccipReceive
), preventing him from executing the intended operation.
Protocol Insolvency
Temporary freezing of funds for at least 30 days
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol (not lower than $1K))
References
It is worth noting that a similar issue was previously identified in an older audit conducted by Veridise on 17.12.2024, titled 4.1.5 V-CSC-VUL-005: Attacker can DoS CCIP messages that include LBTC transfers due to missing offchainTokenData validation
. This issue was resolved in commit 080220c
. However, due to extensive refactoring in the current protocol version, a similar vulnerability has re-emerged, and the risk is available.
Proof of Concept
PoC
Running the test offchain data not related to the payload
shows that offchainTokenData
is not enforced to be associated with the payload (i.e. the bridged message). Thus, it is possible to use offchainTokenData
for an unrelated message, to consume the message in OffRamp
.
import {
LBTCMock,
Bascule,
Consortium,
Bridge,
MockCCIPRouter,
MockRMN,
LombardTokenPool,
CLAdapter,
EndpointV2Mock,
} from '../typechain-types';
import {
takeSnapshot,
SnapshotRestorer,
} from '@nomicfoundation/hardhat-toolbox/network-helpers';
import {
getSignersWithPrivateKeys,
deployContract,
CHAIN_ID,
getPayloadForAction,
NEW_VALSET,
DEPOSIT_BRIDGE_ACTION,
encode,
signDepositBridgePayload,
Signer,
} from './helpers';
import { ethers } from 'hardhat';
import { expect } from 'chai';
import { bridge } from '../typechain-types/contracts';
import { ZeroAddress } from 'ethers';
import { randomBytes } from 'crypto';
const aChainSelector = 1;
const bChainSelector = 2;
describe('Bridge', function () {
let deployer: Signer,
signer1: Signer,
signer2: Signer,
signer3: Signer,
Alice: Signer,
Bob: Signer,
treasurySource: Signer,
treasuryDestination: Signer,
reporter: Signer,
admin: Signer,
pauser: Signer;
let lbtcSource: LBTCMock;
let lbtcDestination: LBTCMock;
let consortium: Consortium;
let bascule: Bascule;
let bridgeSource: Bridge;
let bridgeDestination: Bridge;
let snapshot: SnapshotRestorer;
const absoluteFee = 100n;
before(async function () {
[
deployer,
signer1,
signer2,
signer3,
Alice,
Bob,
treasurySource,
treasuryDestination,
admin,
pauser,
reporter,
] = await getSignersWithPrivateKeys();
// for both chains
consortium = await deployContract<Consortium>('Consortium', [
deployer.address,
]);
await consortium.setInitialValidatorSet(
getPayloadForAction([1, [signer1.publicKey], [1], 1, 1], NEW_VALSET)
);
// chain 1
lbtcSource = await deployContract<LBTCMock>('LBTCMock', [
await consortium.getAddress(),
100,
treasurySource.address,
deployer.address,
]);
bridgeSource = await deployContract<Bridge>('Bridge', [
await lbtcSource.getAddress(),
treasurySource.address,
deployer.address,
]);
bascule = await deployContract<Bascule>(
'Bascule',
[
admin.address,
pauser.address,
reporter.address,
await lbtcSource.getAddress(),
100,
],
false
);
// chain 2
lbtcDestination = await deployContract<LBTCMock>('LBTCMock', [
await consortium.getAddress(),
100,
treasuryDestination.address,
deployer.address,
]);
bridgeDestination = await deployContract<Bridge>('Bridge', [
await lbtcDestination.getAddress(),
treasuryDestination.address,
deployer.address,
]);
await lbtcSource.addMinter(await bridgeSource.getAddress());
await lbtcDestination.addMinter(await bridgeDestination.getAddress());
await bridgeSource.changeConsortium(await consortium.getAddress());
await bridgeDestination.changeConsortium(await consortium.getAddress());
// set rate limits
const oo = {
chainId: CHAIN_ID,
limit: 1_0000_0000n, // 1 LBTC
window: 100,
};
await bridgeSource.addDestination(
CHAIN_ID,
encode(['address'], [await bridgeDestination.getAddress()]),
1000, // 10%
0,
ethers.ZeroAddress,
true
);
await bridgeDestination.addDestination(
CHAIN_ID,
encode(['address'], [await bridgeSource.getAddress()]),
0, // 0%
absoluteFee,
ethers.ZeroAddress,
true
);
await bridgeSource.setRateLimits([oo], [oo]);
await bridgeDestination.setRateLimits([oo], [oo]);
snapshot = await takeSnapshot();
});
afterEach(async function () {
await snapshot.restore();
});
describe('Actions/Flows', function () {
const AMOUNT = 1_0000_0000n; // 1 LBTC
beforeEach(async function () {
await lbtcSource.mintTo(signer1.address, AMOUNT);
});
describe('With Chainlink Adapter', function () {
let CCIPRouter: MockCCIPRouter,
CCIPRMN: MockRMN,
aTokenPool: LombardTokenPool,
bTokenPool: LombardTokenPool,
aCLAdapter: CLAdapter,
bCLAdapter: CLAdapter;
const aCCIPFee = 1_0000_0000n; // 1 gwei
const bCCIPFee = 10_0000_0000n; // 10 gwei
beforeEach(async function () {
// configure CCIP
CCIPRouter = await deployContract<MockCCIPRouter>(
'MockCCIPRouter',
[], // [aChainSelector, bChainSelector],
false
);
await CCIPRouter.setFee(aCCIPFee);
CCIPRMN = await deployContract<MockRMN>('MockRMN', [], false);
aCLAdapter = await deployContract<CLAdapter>(
'CLAdapter',
[
await bridgeSource.getAddress(),
300_000,
//
await CCIPRouter.getAddress(),
[], // no allowlist
await CCIPRMN.getAddress(), // will do work of rmn as well,
],
false
);
aTokenPool = await ethers.getContractAt(
'LombardTokenPool',
await aCLAdapter.tokenPool()
);
await aTokenPool.acceptOwnership();
await aCLAdapter.setRemoteChainSelector(
CHAIN_ID,
bChainSelector
);
bCLAdapter = await deployContract<CLAdapter>(
'CLAdapter',
[
await bridgeDestination.getAddress(),
300_000,
//
await CCIPRouter.getAddress(),
[], // no allowlist
await CCIPRMN.getAddress(), // will do work of rmn as well
],
false
);
bTokenPool = await ethers.getContractAt(
'LombardTokenPool',
await bCLAdapter.tokenPool()
);
await bTokenPool.acceptOwnership();
await bCLAdapter.setRemoteChainSelector(
CHAIN_ID,
aChainSelector
);
/// configure bridges
await bridgeSource.changeAdapter(
CHAIN_ID,
await aCLAdapter.getAddress()
);
await bridgeDestination.changeAdapter(
CHAIN_ID,
await bCLAdapter.getAddress()
);
/// set token pools
await aTokenPool.applyChainUpdates([
{
remoteChainSelector: bChainSelector,
allowed: true,
remotePoolAddress: await bTokenPool.getAddress(),
remoteTokenAddress: await lbtcDestination.getAddress(),
inboundRateLimiterConfig: {
isEnabled: false,
rate: 0,
capacity: 0,
},
outboundRateLimiterConfig: {
isEnabled: false,
rate: 0,
capacity: 0,
},
},
]);
await bTokenPool.applyChainUpdates([
{
remoteChainSelector: aChainSelector,
allowed: true,
remotePoolAddress: await aTokenPool.getAddress(),
remoteTokenAddress: await lbtcSource.getAddress(),
inboundRateLimiterConfig: {
isEnabled: false,
rate: 0,
capacity: 0,
},
outboundRateLimiterConfig: {
isEnabled: false,
rate: 0,
capacity: 0,
},
},
]);
await aTokenPool.setRemotePool(
bChainSelector,
ethers.zeroPadValue(await bTokenPool.getAddress(), 32)
);
await bTokenPool.setRemotePool(
aChainSelector,
ethers.zeroPadValue(await aTokenPool.getAddress(), 32)
);
});
it('offchain data not related to the payload', async function () {
let AliceAmount = 1;
let BobAmount = 100;
const data = await signDepositBridgePayload(
[signer1],
[true],
CHAIN_ID,
await bridgeSource.getAddress(),
CHAIN_ID,
await bridgeDestination.getAddress(),
Alice.address,
AliceAmount
);
const AliceOffChainData = encode(['bytes', 'bytes'], [data.payload, data.proof]);
// using Bob message data but with Alice Offchain data
const snapshot = await takeSnapshot();
await bTokenPool.releaseOrMint({
originalSender: ethers.zeroPadValue(await bridgeSource.getAddress(), 32),
remoteChainSelector: aChainSelector,
receiver: Bob.address,
amount: BobAmount,
localToken: await lbtcDestination.getAddress(),
sourcePoolAddress: ethers.zeroPadValue(await aTokenPool.getAddress(), 32),
sourcePoolData: "0x11223344",
offchainTokenData: AliceOffChainData
})
expect(await lbtcDestination.balanceOf(Alice.address)).to.eq(1);
await snapshot.restore();
// using Alice message data but with Alice Offchain data
await bTokenPool.releaseOrMint({
originalSender: ethers.zeroPadValue(await bridgeSource.getAddress(), 32),
remoteChainSelector: aChainSelector,
receiver: Alice.address,
amount: AliceAmount,
localToken: await lbtcDestination.getAddress(),
sourcePoolAddress: ethers.zeroPadValue(await aTokenPool.getAddress(), 32),
sourcePoolData: "0xaabbccdd",
offchainTokenData: AliceOffChainData
})
expect(await lbtcDestination.balanceOf(Alice.address)).to.eq(1);
});
});
});
});
Last updated
Was this helpful?