Skip to content

Commit 2e3fbe4

Browse files
nicholaspaibmzig
andauthored
feat(BundleDataClient): Support refunds for pre-fills/slow-fill-requests and duplicate deposits (#835)
Co-authored-by: bmzig <57361391+bmzig@users.noreply.github.com>
1 parent 32eb329 commit 2e3fbe4

File tree

11 files changed

+522
-296
lines changed

11 files changed

+522
-296
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@across-protocol/sdk",
33
"author": "UMA Team",
4-
"version": "3.4.20",
4+
"version": "4.0.0",
55
"license": "AGPL-3.0",
66
"homepage": "https://docs.across.to/reference/sdk",
77
"files": [

src/clients/BundleDataClient/BundleDataClient.ts

Lines changed: 377 additions & 260 deletions
Large diffs are not rendered by default.

src/clients/BundleDataClient/utils/FillUtils.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import _ from "lodash";
22
import { providers } from "ethers";
3-
import { DepositWithBlock, Fill, FillWithBlock } from "../../../interfaces";
3+
import { Deposit, DepositWithBlock, Fill, FillWithBlock } from "../../../interfaces";
44
import { getBlockRangeForChain, isSlowFill, chainIsEvm, isValidEvmAddress, isDefined } from "../../../utils";
55
import { HubPoolClient } from "../../HubPoolClient";
66

@@ -47,34 +47,51 @@ export function getRefundInformationFromFill(
4747
};
4848
}
4949

50+
export function getRepaymentChainId(fill: Fill, matchedDeposit: Deposit): number {
51+
// Lite chain deposits force repayment on origin chain.
52+
return matchedDeposit.fromLiteChain ? fill.originChainId : fill.repaymentChainId;
53+
}
54+
55+
export function isEvmRepaymentValid(
56+
fill: Fill,
57+
repaymentChainId: number,
58+
possibleRepaymentChainIds: number[] = []
59+
): boolean {
60+
// Slow fills don't result in repayments so they're always valid.
61+
if (isSlowFill(fill)) {
62+
return true;
63+
}
64+
// Return undefined if the requested repayment chain ID is not in a passed in set of eligible chains. This can
65+
// be used by the caller to narrow the chains to those that are not disabled in the config store.
66+
if (possibleRepaymentChainIds.length > 0 && !possibleRepaymentChainIds.includes(repaymentChainId)) {
67+
return false;
68+
}
69+
return chainIsEvm(repaymentChainId) && isValidEvmAddress(fill.relayer);
70+
}
71+
5072
// Verify that a fill sent to an EVM chain has a 20 byte address. If the fill does not, then attempt
5173
// to repay the `msg.sender` of the relay transaction. Otherwise, return undefined.
5274
export async function verifyFillRepayment(
53-
fill: FillWithBlock,
75+
_fill: FillWithBlock,
5476
destinationChainProvider: providers.Provider,
5577
matchedDeposit: DepositWithBlock,
56-
possibleRepaymentChainIds: number[]
78+
possibleRepaymentChainIds: number[] = []
5779
): Promise<FillWithBlock | undefined> {
58-
// Slow fills don't result in repayments so they're always valid.
59-
if (isSlowFill(fill)) {
60-
return fill;
61-
}
62-
// Lite chain deposits force repayment on origin chain.
63-
const repaymentChainId = matchedDeposit.fromLiteChain ? fill.originChainId : fill.repaymentChainId;
64-
// Return undefined if the requested repayment chain ID is not recognized by the hub pool.
65-
if (!possibleRepaymentChainIds.includes(repaymentChainId)) {
66-
return undefined;
67-
}
68-
const updatedFill = _.cloneDeep(fill);
80+
const fill = _.cloneDeep(_fill);
6981

70-
// If the fill requests repayment on a chain where the repayment address is not valid, attempt to find a valid
71-
// repayment address, otherwise return undefined.
82+
const repaymentChainId = getRepaymentChainId(fill, matchedDeposit);
83+
const validEvmRepayment = isEvmRepaymentValid(fill, repaymentChainId, possibleRepaymentChainIds);
7284

73-
// Case 1: repayment chain is an EVM chain but repayment address is not a valid EVM address.
74-
if (chainIsEvm(repaymentChainId) && !isValidEvmAddress(updatedFill.relayer)) {
85+
// Case 1: Repayment chain is EVM and repayment address is valid EVM address.
86+
if (validEvmRepayment) {
87+
return fill;
88+
}
89+
// Case 2: Repayment chain is EVM but repayment address is not a valid EVM address. Attempt to switch repayment
90+
// address to msg.sender of relay transaction.
91+
else if (chainIsEvm(repaymentChainId) && !isValidEvmAddress(fill.relayer)) {
7592
// TODO: Handle case where fill was sent on non-EVM chain, in which case the following call would fail
7693
// or return something unexpected. We'd want to return undefined here.
77-
const fillTransaction = await destinationChainProvider.getTransaction(updatedFill.transactionHash);
94+
const fillTransaction = await destinationChainProvider.getTransaction(fill.transactionHash);
7895
const destinationRelayer = fillTransaction?.from;
7996
// Repayment chain is still an EVM chain, but the msg.sender is a bytes32 address, so the fill is invalid.
8097
if (!isDefined(destinationRelayer) || !isValidEvmAddress(destinationRelayer)) {
@@ -83,9 +100,11 @@ export async function verifyFillRepayment(
83100
// Otherwise, assume the relayer to be repaid is the msg.sender. We don't need to modify the repayment chain since
84101
// the getTransaction() call would only succeed if the fill was sent on an EVM chain and therefore the msg.sender
85102
// is a valid EVM address and the repayment chain is an EVM chain.
86-
updatedFill.relayer = destinationRelayer;
103+
fill.relayer = destinationRelayer;
104+
return fill;
105+
}
106+
// Case 3: Repayment chain is not an EVM chain, must be invalid.
107+
else {
108+
return undefined;
87109
}
88-
89-
// Case 2: TODO repayment chain is an SVM chain and repayment address is not a valid SVM address.
90-
return updatedFill;
91110
}

src/clients/SpokePoolClient.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class SpokePoolClient extends BaseAbstractClient {
6767
protected currentTime = 0;
6868
protected oldestTime = 0;
6969
protected depositHashes: { [depositHash: string]: DepositWithBlock } = {};
70+
protected duplicateDepositHashes: { [depositHash: string]: DepositWithBlock[] } = {};
7071
protected depositHashesToFills: { [depositHash: string]: FillWithBlock[] } = {};
7172
protected speedUps: { [depositorAddress: string]: { [depositId: string]: SpeedUpWithBlock[] } } = {};
7273
protected slowFillRequests: { [relayDataHash: string]: SlowFillRequestWithBlock } = {};
@@ -126,14 +127,42 @@ export class SpokePoolClient extends BaseAbstractClient {
126127
}
127128

128129
/**
129-
* Retrieves a list of deposits from the SpokePool contract destined for the given destination chain ID.
130+
* Retrieves a list of unique deposits from the SpokePool contract destined for the given destination chain ID.
130131
* @param destinationChainId The destination chain ID.
131132
* @returns A list of deposits.
132133
*/
133134
public getDepositsForDestinationChain(destinationChainId: number): DepositWithBlock[] {
134135
return Object.values(this.depositHashes).filter((deposit) => deposit.destinationChainId === destinationChainId);
135136
}
136137

138+
/**
139+
* Retrieves a list of duplicate deposits matching the given deposit's deposit hash.
140+
* @notice A duplicate is considered any deposit sent after the original deposit with the same deposit hash.
141+
* @param deposit The deposit to find duplicates for.
142+
* @returns A list of duplicate deposits. Does NOT include the original deposit
143+
* unless the original deposit is a duplicate.
144+
*/
145+
private _getDuplicateDeposits(deposit: DepositWithBlock): DepositWithBlock[] {
146+
const depositHash = this.getDepositHash(deposit);
147+
return this.duplicateDepositHashes[depositHash] ?? [];
148+
}
149+
150+
/**
151+
* Returns a list of all deposits including any duplicate ones. Designed only to be used in use cases where
152+
* all deposits are required, regardless of duplicates. For example, the Dataworker can use this to refund
153+
* expired deposits including for duplicates.
154+
* @param destinationChainId
155+
* @returns A list of deposits
156+
*/
157+
public getDepositsForDestinationChainWithDuplicates(destinationChainId: number): DepositWithBlock[] {
158+
const deposits = this.getDepositsForDestinationChain(destinationChainId);
159+
const duplicateDeposits = deposits.reduce((acc, deposit) => {
160+
const duplicates = this._getDuplicateDeposits(deposit);
161+
return acc.concat(duplicates);
162+
}, [] as DepositWithBlock[]);
163+
return sortEventsAscendingInPlace(deposits.concat(duplicateDeposits.flat()));
164+
}
165+
137166
/**
138167
* Retrieves a list of deposits from the SpokePool contract that are associated with this spoke pool.
139168
* @returns A list of deposits.
@@ -579,6 +608,7 @@ export class SpokePoolClient extends BaseAbstractClient {
579608
}
580609

581610
if (this.depositHashes[this.getDepositHash(deposit)] !== undefined) {
611+
assign(this.duplicateDepositHashes, [this.getDepositHash(deposit)], [deposit]);
582612
continue;
583613
}
584614
assign(this.depositHashes, [this.getDepositHash(deposit)], deposit);

src/clients/mocks/MockSpokePoolClient.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@ export class MockSpokePoolClient extends SpokePoolClient {
123123
const { blockNumber, transactionIndex } = deposit;
124124
let { depositId, depositor, destinationChainId, inputToken, inputAmount, outputToken, outputAmount } = deposit;
125125
depositId ??= this.numberOfDeposits;
126-
assert(depositId.gte(this.numberOfDeposits), `${depositId.toString()} < ${this.numberOfDeposits}`);
127126
this.numberOfDeposits = depositId.add(bnOne);
128127

129128
destinationChainId ??= random(1, 42161, false);

src/constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ export const SECONDS_PER_YEAR = 31557600; // 365.25 days per year.
2626
*/
2727
export const HUBPOOL_CHAIN_ID = 1;
2828

29-
// List of versions where certain UMIP features were deprecated
29+
// List of versions where certain UMIP features were deprecated or activated
3030
export const TRANSFER_THRESHOLD_MAX_CONFIG_STORE_VERSION = 1;
3131

32+
export const PRE_FILL_MIN_CONFIG_STORE_VERSION = 5;
33+
3234
// A hardcoded identifier used, by default, to tag all Arweave records.
3335
export const ARWEAVE_TAG_APP_NAME = "across-protocol";
3436

src/utils/DepositUtils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "assert";
22
import { SpokePoolClient } from "../clients";
3-
import { DEFAULT_CACHING_TTL, EMPTY_MESSAGE } from "../constants";
3+
import { DEFAULT_CACHING_TTL, EMPTY_MESSAGE, ZERO_BYTES } from "../constants";
44
import { CachingMechanismInterface, Deposit, DepositWithBlock, Fill, SlowFillRequest } from "../interfaces";
55
import { getNetworkName } from "./NetworkUtils";
66
import { getDepositInCache, getDepositKey, setDepositInCache } from "./CachingUtils";
@@ -146,6 +146,10 @@ export function isZeroValueDeposit(deposit: Pick<Deposit, "inputAmount" | "messa
146146
return deposit.inputAmount.eq(0) && isMessageEmpty(deposit.message);
147147
}
148148

149+
export function isZeroValueFillOrSlowFillRequest(e: Pick<Fill | SlowFillRequest, "inputAmount" | "message">): boolean {
150+
return e.inputAmount.eq(0) && isFillOrSlowFillRequestMessageEmpty(e.message);
151+
}
152+
149153
/**
150154
* Determines if a message is empty or not.
151155
* @param message The message to check.
@@ -155,6 +159,10 @@ export function isMessageEmpty(message = EMPTY_MESSAGE): boolean {
155159
return message === "" || message === "0x";
156160
}
157161

162+
export function isFillOrSlowFillRequestMessageEmpty(message: string): boolean {
163+
return isMessageEmpty(message) || message === ZERO_BYTES;
164+
}
165+
158166
/**
159167
* Determines if a deposit was updated via a speed-up transaction.
160168
* @param deposit Deposit to evaluate.

src/utils/SpokeUtils.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import assert from "assert";
22
import { BytesLike, Contract, PopulatedTransaction, providers, utils as ethersUtils } from "ethers";
33
import { CHAIN_IDs, MAX_SAFE_DEPOSIT_ID, ZERO_ADDRESS, ZERO_BYTES } from "../constants";
4-
import { Deposit, Fill, FillStatus, RelayData, SlowFillRequest } from "../interfaces";
4+
import { Deposit, Fill, FillStatus, FillWithBlock, RelayData, SlowFillRequest } from "../interfaces";
55
import { SpokePoolClient } from "../clients";
66
import { chunk } from "./ArrayUtils";
77
import { BigNumber, toBN, bnOne, bnZero } from "./BigNumberUtils";
88
import { keccak256 } from "./common";
99
import { isMessageEmpty } from "./DepositUtils";
1010
import { isDefined } from "./TypeGuards";
1111
import { getNetworkName } from "./NetworkUtils";
12+
import { paginatedEventQuery, spreadEventWithBlockNumber } from "./EventUtils";
1213

1314
type BlockTag = providers.BlockTag;
1415

@@ -351,12 +352,11 @@ export async function findFillBlock(
351352
): Promise<number | undefined> {
352353
const { provider } = spokePool;
353354
highBlockNumber ??= await provider.getBlockNumber();
354-
assert(highBlockNumber > lowBlockNumber, `Block numbers out of range (${lowBlockNumber} > ${highBlockNumber})`);
355+
assert(highBlockNumber > lowBlockNumber, `Block numbers out of range (${lowBlockNumber} >= ${highBlockNumber})`);
355356

356357
// In production the chainId returned from the provider matches 1:1 with the actual chainId. Querying the provider
357358
// object saves an RPC query becasue the chainId is cached by StaticJsonRpcProvider instances. In hre, the SpokePool
358359
// may be configured with a different chainId than what is returned by the provider.
359-
// @todo Sub out actual chain IDs w/ CHAIN_IDs constants
360360
const destinationChainId = Object.values(CHAIN_IDs).includes(relayData.originChainId)
361361
? (await provider.getNetwork()).chainId
362362
: Number(await spokePool.chainId());
@@ -399,6 +399,39 @@ export async function findFillBlock(
399399
return lowBlockNumber;
400400
}
401401

402+
export async function findFillEvent(
403+
spokePool: Contract,
404+
relayData: RelayData,
405+
lowBlockNumber: number,
406+
highBlockNumber?: number
407+
): Promise<FillWithBlock | undefined> {
408+
const blockNumber = await findFillBlock(spokePool, relayData, lowBlockNumber, highBlockNumber);
409+
if (!blockNumber) return undefined;
410+
const query = await paginatedEventQuery(
411+
spokePool,
412+
spokePool.filters.FilledV3Relay(null, null, null, null, null, relayData.originChainId, relayData.depositId),
413+
{
414+
fromBlock: blockNumber,
415+
toBlock: blockNumber,
416+
maxBlockLookBack: 0, // We can hardcode this to 0 to instruct paginatedEventQuery to make a single request
417+
// for the same block number.
418+
}
419+
);
420+
if (query.length === 0) throw new Error(`Failed to find fill event at block ${blockNumber}`);
421+
const event = query[0];
422+
// In production the chainId returned from the provider matches 1:1 with the actual chainId. Querying the provider
423+
// object saves an RPC query becasue the chainId is cached by StaticJsonRpcProvider instances. In hre, the SpokePool
424+
// may be configured with a different chainId than what is returned by the provider.
425+
const destinationChainId = Object.values(CHAIN_IDs).includes(relayData.originChainId)
426+
? (await spokePool.provider.getNetwork()).chainId
427+
: Number(await spokePool.chainId());
428+
const fill = {
429+
...spreadEventWithBlockNumber(event),
430+
destinationChainId,
431+
} as FillWithBlock;
432+
return fill;
433+
}
434+
402435
// Determines if the input address (either a bytes32 or bytes20) is the zero address.
403436
export function isZeroAddress(address: string): boolean {
404437
return address === ZERO_ADDRESS || address === ZERO_BYTES;

test/SpokePoolClient.SpeedUp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,13 @@ describe("SpokePoolClient: SpeedUp", function () {
215215
// attributed to the existing deposit.
216216
for (const field of ["originChainId", "depositId", "depositor"]) {
217217
const testOriginChainId = field !== "originChainId" ? originChainId : originChainId + 1;
218-
const testDepositId = field !== "depositId" ? depositId : depositId + 1;
218+
const testDepositId = field !== "depositId" ? depositId : depositId.add(1);
219219
const testDepositor = field !== "depositor" ? depositor : (await ethers.getSigners())[0];
220220
assert.isTrue(field !== "depositor" || testDepositor.address !== depositor.address); // Sanity check
221221

222222
const signature = await getUpdatedV3DepositSignature(
223223
testDepositor,
224-
testDepositId,
224+
testDepositId.toNumber(),
225225
testOriginChainId,
226226
updatedOutputAmount,
227227
updatedRecipient,

test/SpokePoolClient.ValidateFill.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ describe("SpokePoolClient: Fill Validation", function () {
615615

616616
// Override the first spoke pool deposit ID that the client thinks is available in the contract.
617617
await spokePoolClient1.update();
618-
spokePoolClient1.firstDepositIdForSpokePool = deposit.depositId + 1;
618+
spokePoolClient1.firstDepositIdForSpokePool = deposit.depositId.add(1);
619619
expect(fill.depositId < spokePoolClient1.firstDepositIdForSpokePool).is.true;
620620
const search = await queryHistoricalDepositForFill(spokePoolClient1, fill);
621621

@@ -636,7 +636,7 @@ describe("SpokePoolClient: Fill Validation", function () {
636636
);
637637

638638
// Override the deposit ID that we are "filling" to be > 1, the latest deposit ID in spoke pool 1.
639-
await fillV3Relay(spokePool_2, { ...deposit, depositId: deposit.depositId + 1 }, relayer);
639+
await fillV3Relay(spokePool_2, { ...deposit, depositId: deposit.depositId.add(1) }, relayer);
640640
await spokePoolClient2.update();
641641
const [fill] = spokePoolClient2.getFills();
642642

0 commit comments

Comments
 (0)