Skip to content
Closed
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
27 changes: 27 additions & 0 deletions src/orders/erc20Fulfillment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ function basicOrderErc20Payment(
return { token, amount: total }
}

/**
* Whether an advanced order's numerator/denominator fraction represents a
* full fill. A full fill is the only case where summing the consideration
* items' `startAmount`s yields the exact payment: for a partial fill Seaport
* scales each item by the fraction and requires exact divisibility, which the
* SDK does not model here. Returns false for a missing or unparsable value, a
* non-positive value, or an unequal fraction, so the caller fails open and
* skips the preflight.
*/
function isFullFillFraction(order: Record<string, unknown>): boolean {
const numerator = toBigInt(order.numerator)
const denominator = toBigInt(order.denominator)
if (numerator === null || denominator === null) {
return false
}
return numerator > 0n && numerator === denominator
}

/**
* Read the ERC20 payment a fulfiller owes from the `inputData` of an OpenSea
* fulfillment response.
Expand Down Expand Up @@ -184,6 +202,15 @@ export function getErc20Payment(inputData: unknown): Erc20Payment | null {
return null
}

// An AdvancedOrder may carry a numerator/denominator fraction representing a
// partial fill. Seaport applies that fraction to each consideration item and
// requires exact divisibility, so the summed full-order consideration is only
// a confident preflight amount for a full fill. For any other fraction, fail
// open and skip the preflight rather than falsely blocking a purchase.
if (isRecord(inputData.advancedOrder) && !isFullFillFraction(order)) {
return null
}

return sumErc20Consideration(order.parameters.consideration)
}

Expand Down
59 changes: 56 additions & 3 deletions test/orders/erc20Fulfillment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,18 @@ function erc721Item(tokenId: string, recipient = SELLER) {
}
}

function advancedOrderInput(consideration: unknown[]) {
function advancedOrderInput(
consideration: unknown[],
{
numerator = 1,
denominator = 1,
}: { numerator?: unknown; denominator?: unknown } = {},
) {
return {
advancedOrder: {
parameters: { offerer: SELLER, consideration },
numerator: 1,
denominator: 1,
numerator,
denominator,
signature: "0x",
extraData: "0x",
},
Expand Down Expand Up @@ -99,6 +105,53 @@ describe("getErc20Payment: advanced and standard orders", () => {
expect(payment).toEqual({ token: USDG, amount: 360000000n })
})

test("returns null for a partial-fill advanced order, not the full-order sum", () => {
// A 1/4 fill of seller 80 + fee 20 really costs 25; the full 100 is not a
// confident preflight amount, so the helper must fail open.
expect(
getErc20Payment(
advancedOrderInput(
[erc20Item(USDG, "80"), erc20Item(USDG, "20", FEE_RECIPIENT)],
{ numerator: 1, denominator: 4 },
),
),
).toBe(null)
})

test("treats an equal positive fraction as a full fill (e.g. 2/2)", () => {
expect(
getErc20Payment(
advancedOrderInput(
[erc20Item(USDG, "80"), erc20Item(USDG, "20", FEE_RECIPIENT)],
{ numerator: 2, denominator: 2 },
),
),
).toEqual({ token: USDG, amount: 100n })
})

test("returns null for a missing or unparsable fraction, and for zero", () => {
const items = [erc20Item(USDG, "80"), erc20Item(USDG, "20", FEE_RECIPIENT)]

const missingNumerator = advancedOrderInput(items)
delete (missingNumerator.advancedOrder as Record<string, unknown>).numerator
expect(getErc20Payment(missingNumerator)).toBe(null)

const missingDenominator = advancedOrderInput(items)
delete (missingDenominator.advancedOrder as Record<string, unknown>)
.denominator
expect(getErc20Payment(missingDenominator)).toBe(null)

expect(
getErc20Payment(advancedOrderInput(items, { numerator: "nope" })),
).toBe(null)
expect(getErc20Payment(advancedOrderInput(items, { numerator: 0 }))).toBe(
null,
)
expect(getErc20Payment(advancedOrderInput(items, { denominator: 0 }))).toBe(
null,
)
})

test("reads the same consideration from a fulfillOrder input shape", () => {
const payment = getErc20Payment({
order: {
Expand Down
57 changes: 51 additions & 6 deletions test/sdk/fulfillmentManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,16 @@ describe("SDK: FulfillmentManager", () => {
/** Price the mock listing in USDG: 351 to the seller plus a 9 USDG fee. */
function priceInErc20({
fulfillerConduitKey = CONDUIT_KEY,
numerator = 1,
denominator = 1,
seller = "351000000",
fee = "9000000",
}: {
fulfillerConduitKey?: string
numerator?: number
denominator?: number
seller?: string
fee?: string
} = {}) {
mockAPI.generateFulfillmentData.mockResolvedValue({
fulfillmentData: {
Expand All @@ -441,22 +449,22 @@ describe("SDK: FulfillmentManager", () => {
itemType: 1,
token: USDG,
identifierOrCriteria: "0",
startAmount: "351000000",
endAmount: "351000000",
startAmount: seller,
endAmount: seller,
recipient: SELLER,
},
{
itemType: 1,
token: USDG,
identifierOrCriteria: "0",
startAmount: "9000000",
endAmount: "9000000",
startAmount: fee,
endAmount: fee,
recipient: "0x0000a26b00c1f0df003000390027140000faa719",
},
],
},
numerator: 1,
denominator: 1,
numerator,
denominator,
signature: "0x",
extraData: "0x",
},
Expand Down Expand Up @@ -626,6 +634,43 @@ describe("SDK: FulfillmentManager", () => {

expect(readContract).not.toHaveBeenCalled()
})

test("does not falsely reject a partial fill whose fraction lowers the payment", async () => {
// A 1/4 fill of seller 80 + fee 20 really costs 25, and the buyer has
// exactly that. The preflight must skip rather than demand the full 100.
priceInErc20({ numerator: 1, denominator: 4, seller: "80", fee: "20" })

const result = await fulfillmentManager.fulfillOrder({
order: mockOrderV2,
accountAddress: "0xBuyer",
})

expect(result).toBe("0xFulfillTxHash")
expect(mockSigner.sendTransaction).toHaveBeenCalledTimes(1)

// Skipping the preflight means no onchain balance/allowance reads.
const readContract = (
mockContext.contractCaller as unknown as {
readContract: ReturnType<typeof vi.fn>
}
).readContract
expect(readContract).not.toHaveBeenCalled()
})

test("still preflights an equal positive fraction (2/2) as a full fill", async () => {
// 2/2 represents a full fill, so the full 360000000 is required and the
// preflight must still reject an insufficient allowance.
priceInErc20({ numerator: 2, denominator: 2 })
stubErc20Reads({ balance: 500000000n, allowance: 100n })

await expect(
fulfillmentManager.fulfillOrder({
order: mockOrderV2,
accountAddress: "0xBuyer",
}),
).rejects.toThrow(/not approved/)
expect(mockSigner.sendTransaction).not.toHaveBeenCalled()
})
})

describe("fulfillOrder with remaining_quantity", () => {
Expand Down