diff --git a/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.test.ts b/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.test.ts index d5d54af3..6ea58401 100644 --- a/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.test.ts +++ b/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.test.ts @@ -349,7 +349,7 @@ describe("V2DutchOrderBuilder", () => { .outputOverrides([OUTPUT_START_AMOUNT.mul(102).div(100)]) .build() ).toThrow( - "Invariant failed: inputOverride not set or larger than original input" + "Invariant failed: inputOverride larger than original input" ); }); @@ -574,7 +574,7 @@ describe("V2DutchOrderBuilder", () => { }); describe("partial order tests", () => { - it("builds an unsigned partial order with default cosignerData values", () => { + it("builds an unsigned partial order with default cosignerData values", () => { //TODO: partial orders don't have cosignerData... const deadline = Math.floor(Date.now() / 1000) + 1000; const order = builder .cosigner(constants.AddressZero) diff --git a/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.ts b/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.ts index d8a9c8df..79e396a2 100644 --- a/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.ts +++ b/sdks/uniswapx-sdk/src/builder/V2DutchOrderBuilder.ts @@ -40,7 +40,7 @@ export class V2DutchOrderBuilder extends OrderBuilder { builder.output(output); } - if (isCosigned(order)) { + if (order instanceof CosignedV2DutchOrder) { builder.cosignature(order.info.cosignature); builder.decayEndTime(order.info.cosignerData.decayEndTime); builder.decayStartTime(order.info.cosignerData.decayStartTime); @@ -287,9 +287,8 @@ export class V2DutchOrderBuilder extends OrderBuilder { "exclusivityOverrideBps not set" ); invariant( - this.info.cosignerData.inputOverride !== undefined && this.info.cosignerData.inputOverride.lte(this.info.input.startAmount), - "inputOverride not set or larger than original input" + "inputOverride larger than original input" ); invariant( this.info.cosignerData.outputOverrides.length > 0, @@ -339,9 +338,3 @@ export class V2DutchOrderBuilder extends OrderBuilder { }; } } - -function isCosigned( - order: UnsignedV2DutchOrder | CosignedV2DutchOrder -): order is CosignedV2DutchOrder { - return (order as CosignedV2DutchOrder).info.cosignature !== undefined; -} diff --git a/sdks/uniswapx-sdk/src/builder/V3DutchOrderBuilder.test.ts b/sdks/uniswapx-sdk/src/builder/V3DutchOrderBuilder.test.ts new file mode 100644 index 00000000..e3b1bab9 --- /dev/null +++ b/sdks/uniswapx-sdk/src/builder/V3DutchOrderBuilder.test.ts @@ -0,0 +1,812 @@ +import { BigNumber, constants } from "ethers"; + +import { CosignedV3DutchOrder, UnsignedV3DutchOrder } from "../order/V3DutchOrder"; +import { encodeExclusiveFillerData } from "../order/validation"; + +import { V3DutchOrderBuilder } from "./V3DutchOrderBuilder"; + +const INPUT_TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +const OUTPUT_TOKEN = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; + +const INPUT_START_AMOUNT = BigNumber.from("1000000"); +const OUTPUT_START_AMOUNT = BigNumber.from("1000000000000000000"); + +describe("V3DutchOrderBuilder", () => { + let builder: V3DutchOrderBuilder; + + beforeEach(() => { + builder = new V3DutchOrderBuilder(1, constants.AddressZero); + }); + + it("Build a valid order", () => { + const deadline = Date.now() + 1000; + const order = builder + .cosigner(constants.AddressZero) + .cosignature("0x") + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build(); + + expect(order.info.cosignerData.decayStartBlock).toEqual(212121); + expect(order.info.outputs.length).toEqual(1); + }); + //TODO: Add tests that uses the validation contract once it is implemented + + it("Build a valid order with multiple outputs", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + const order = builder + .cosigner(constants.AddressZero) + .cosignature("0x") + .deadline(deadline) + .decayStartBlock(212121) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [17], + relativeAmounts: [BigInt(17)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT.mul(101).div(100), OUTPUT_START_AMOUNT]) + .build(); + expect(order.info.outputs.length).toEqual(2); + expect(order.info.cosignerData.decayStartBlock).toEqual(212121); + }); + + it("Throw if cosigner is not set", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: cosigner not set"); + }); + + it("Throw if relativeBlocks and relativeAmounts length mismatch in output", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(1)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4,5], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: relativeBlocks and relativeAmounts length mismatch"); + }); + + it("Throw if relativeBlocks and relativeAmounts length mismatch in input", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0), BigInt(1)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(BigNumber.from(0)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: relativeBlocks and relativeAmounts length mismatch"); + }); + + it("Throw if relativeBlocks is not strictly increasing in input", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1, 2, 1], + relativeAmounts: [BigInt(1), BigInt(2), BigInt(3)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [], + relativeAmounts: [], + }, + recipient: constants.AddressZero, + }) + .inputOverride(BigNumber.from(0)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .nonce(BigNumber.from(100)) + .swapper(constants.AddressZero) + .build() + ).toThrow("Invariant failed: relativeBlocks not strictly increasing"); + }); + + it("Throw if relativeBlocks is not strictly increasing in output", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [5, 5], + relativeAmounts: [BigInt(4), BigInt(22)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .nonce(BigNumber.from(100)) + .swapper(constants.AddressZero) + .build() + ).toThrow("Invariant failed: relativeBlocks not strictly increasing"); + }); + + it("Throw if swapper is not set", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: swapper not set"); + }); + + it("Deadline not set", () => { + expect(() => builder + .cosigner(constants.AddressZero) + .cosignature("0x") + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: deadline not set"); + }); + + it("Nonce not set", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + // omitting nonce + .build() + ).toThrow("Invariant failed: nonce not set"); + }); + + it("Throw if input is not set", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + // omitting input + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: input not set"); + }); + + it("Throw if output is not set", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + // omitting output + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: outputs not set"); + }); + + it("Throw if inputOverride larger than input", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.add(1)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: inputOverride larger than original input"); + }); + + it("Throw if outputOverride smaller than output", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT.sub(2121)]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: outputOverride smaller than original output"); + }); + + it("Do not enforce endAmount < startAmount for V3", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT, + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(-4)], + }, + recipient: constants.AddressZero, + }) + .deadline(deadline) + .outputOverrides([OUTPUT_START_AMOUNT]) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).not.toThrow(); + }); + + it("Throw if deadline already passed", () => { + const deadline = 2121; + expect(() => + builder + .cosignature("0x") + .cosigner(constants.AddressZero) + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build() + ).toThrow("Invariant failed: Deadline must be in the future: 2121"); + }); + + it("Does not throw before an order has not been finished building", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + expect(() => + builder.deadline(deadline).decayStartBlock(21212121212121) + ).not.toThrowError(); + }); + + it("Unknown chainId", () => { + const chainId = 99999999; + expect(() => new V3DutchOrderBuilder(chainId)).toThrow( + `Missing configuration for reactor: ${chainId}` + ); + }); + + it("Regenerate builder from order JSON", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + const fillerAddress = "0x1111111111111111111111111111111111111111"; + const additionalValidationContract = + "0x2222222222222222222222222222222222222222"; + const timestamp = Math.floor(Date.now() / 1000) + 100; + const validationInfo = encodeExclusiveFillerData( + fillerAddress, + timestamp, + 1, + additionalValidationContract + ); + const order = builder + .cosigner(constants.AddressZero) + .cosignature("0x") + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .validation(validationInfo) + .build(); + + const json = order.toJSON(); + const jsonToOrder = CosignedV3DutchOrder.fromJSON(json, 1); + const regeneratedBuilder = V3DutchOrderBuilder.fromOrder(jsonToOrder); + const regeneratedOrder = regeneratedBuilder.build(); + expect(regeneratedOrder.toJSON()).toMatchObject(order.toJSON()); + }); + + it("Regenerate builder and modify", () => { + const deadline = Math.floor(Date.now() / 1000) + 1000; + const fillerAddress = "0x1111111111111111111111111111111111111111"; + const additionalValidationContract = + "0x2222222222222222222222222222222222222222"; + const timestamp = Math.floor(Date.now() / 1000) + 100; + const validationInfo = encodeExclusiveFillerData( + fillerAddress, + timestamp, + 1, + additionalValidationContract + ); + const order = builder + .cosigner(constants.AddressZero) + .cosignature("0x") + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .validation(validationInfo) + .build(); + + const regeneratedBuilder = V3DutchOrderBuilder.fromOrder(order); + regeneratedBuilder.decayStartBlock(214221422142); + const regeneratedOrder = regeneratedBuilder.build(); + expect(regeneratedOrder.info.cosignerData.decayStartBlock).toEqual(214221422142); + }); + + describe("Partial order tests", () => { + it("Test valid order with buildPartial", () => { + const order = builder + .cosigner(constants.AddressZero) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .deadline(Math.floor(Date.now() / 1000) + 1000) + .buildPartial(); + expect(order.info.outputs.length).toEqual(1); + expect(order.chainId).toBeDefined(); + expect(order.info.reactor).toBeDefined(); + }); + + it("Test invalid order with buildPartial", () => { + expect(() => + builder + .cosigner(constants.AddressZero) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + // omitting swapper + .deadline(Math.floor(Date.now() / 1000) + 1000) + .nonce(BigNumber.from(100)) + .buildPartial() + ).toThrow("Invariant failed: swapper not set"); + }); + + it("Test maxAmountOut", () => { + const startAmount = INPUT_START_AMOUNT; + const relativeAmounts = [BigInt(0), BigInt(3), BigInt(-2), BigInt(-4), BigInt(-3)]; + const maxout = V3DutchOrderBuilder.getMaxAmountOut(startAmount, relativeAmounts); + expect(maxout).toEqual(startAmount.add(4)); + }); + }); + + describe("fromOrder", () => { + let builder: V3DutchOrderBuilder; + + beforeEach(() => { + builder = new V3DutchOrderBuilder(1, constants.AddressZero); + }); + + it("should create a V3DutchOrderBuilder from an UnsignedV3DutchOrder", () => { + const order: UnsignedV3DutchOrder = builder + .cosigner(constants.AddressZero) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .deadline(Math.floor(Date.now() / 1000) + 1000) + .buildPartial(); + + const created_builder = V3DutchOrderBuilder.fromOrder(order); + const created_order = created_builder.buildPartial(); + + expect(created_order.chainId).toEqual(1); + expect(created_order.info.input.token).toEqual(INPUT_TOKEN); + expect(created_order.info.outputs.length).toEqual(1); + }); + + it("should create a V3DutchOrderBuilder from a CosignedV3DutchOrder", () => { + const deadline = Date.now() + 1000; + const order: CosignedV3DutchOrder = builder + .cosigner(constants.AddressZero) + .cosignature("0x") + .decayStartBlock(212121) + .input({ + token: INPUT_TOKEN, + startAmount: INPUT_START_AMOUNT, + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: INPUT_START_AMOUNT.add(1), + }) + .output({ + token: OUTPUT_TOKEN, + startAmount: OUTPUT_START_AMOUNT, + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(4)], + }, + recipient: constants.AddressZero, + }) + .inputOverride(INPUT_START_AMOUNT.mul(99).div(100)) + .outputOverrides([OUTPUT_START_AMOUNT]) + .deadline(deadline) + .swapper(constants.AddressZero) + .nonce(BigNumber.from(100)) + .build(); + const created_builder = V3DutchOrderBuilder.fromOrder(order); + const created_order = created_builder.build(); + expect(created_order.chainId).toEqual(1); + expect(created_order.info.input.token).toEqual(INPUT_TOKEN); + expect(created_order.info.outputs.length).toEqual(1); + expect(created_order.info.cosignerData.decayStartBlock).toEqual(212121); + }); + }); +}); \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/builder/V3DutchOrderBuilder.ts b/sdks/uniswapx-sdk/src/builder/V3DutchOrderBuilder.ts new file mode 100644 index 00000000..a1474695 --- /dev/null +++ b/sdks/uniswapx-sdk/src/builder/V3DutchOrderBuilder.ts @@ -0,0 +1,298 @@ +import { BigNumber, ethers } from "ethers"; +import invariant from "tiny-invariant"; + +import { OrderType } from "../constants"; +import { CosignedV3DutchOrder, CosignedV3DutchOrderInfo, UnsignedV3DutchOrder, UnsignedV3DutchOrderInfo, V3CosignerData } from "../order/V3DutchOrder"; +import { V3DutchInput, V3DutchOutput } from "../order/types"; +import { ValidationInfo } from "../order/validation"; +import { getPermit2, getReactor } from "../utils"; + +import { OrderBuilder } from "./OrderBuilder"; + +export class V3DutchOrderBuilder extends OrderBuilder { + static fromOrder( + order: T + ): V3DutchOrderBuilder { + const builder = new V3DutchOrderBuilder(order.chainId, order.info.reactor); + builder + .cosigner(order.info.cosigner) + .input(order.info.input) + .deadline(order.info.deadline) + .nonce(order.info.nonce) + .swapper(order.info.swapper) + .validation({ + additionalValidationContract: order.info.additionalValidationContract, + additionalValidationData: order.info.additionalValidationData, + }); + + order.info.outputs.forEach((output) => { + builder.output(output); + }); + + if (order instanceof CosignedV3DutchOrder) { + builder.cosignature(order.info.cosignature); + builder.decayStartBlock(order.info.cosignerData.decayStartBlock); + builder.exclusiveFiller(order.info.cosignerData.exclusiveFiller); + builder.inputOverride(order.info.cosignerData.inputOverride); + builder.exclusivityOverrideBps(order.info.cosignerData.exclusivityOverrideBps); + builder.outputOverrides(order.info.cosignerData.outputOverrides); + } + return builder; + } + + build(): CosignedV3DutchOrder { + invariant(this.info.cosignature !== undefined, "cosignature not set"); + this.checkUnsignedInvariants(this.info); + this.checkCosignedInvariants(this.info); + return new CosignedV3DutchOrder( + Object.assign(this.getOrderInfo(), { + cosignerData: this.info.cosignerData, + input: this.info.input, + outputs: this.info.outputs, + cosigner: this.info.cosigner, + cosignature: this.info.cosignature, + }), + this.chainId, + this.permit2Address + ); + } + private permit2Address: string; + private info: Partial; + + constructor( + private chainId: number, + reactorAddress?: string, + _permit2Address?: string + ) { + super(); + + this.reactor(getReactor(chainId, OrderType.Dutch_V3, reactorAddress)); + this.permit2Address = getPermit2(chainId, _permit2Address); + this.info = { + outputs: [], + }; + this.initializeCosignerData({}); + } + + cosigner(cosigner: string): this { + this.info.cosigner = cosigner; + return this; + } + + cosignature(cosignature: string | undefined): this { + this.info.cosignature = cosignature; + return this; + } + + decayStartBlock(decayStartBlock: number): this { + if (!this.info.cosignerData) { + this.initializeCosignerData({ decayStartBlock }); + } else { + this.info.cosignerData.decayStartBlock = decayStartBlock; + } + return this; + } + + private initializeCosignerData(data: Partial): void { + this.info.cosignerData = { + decayStartBlock: 0, + exclusiveFiller: ethers.constants.AddressZero, + exclusivityOverrideBps: BigNumber.from(0), + inputOverride: BigNumber.from(0), + outputOverrides: [], + ...data, + }; + } + + private isRelativeBlocksIncreasing(relativeBlocks: number[]): boolean { + let prevBlock = 0; + for (const block of relativeBlocks) { + if (block <= prevBlock) { + return false; + } + prevBlock = block; + } + return true; + } + + private checkUnsignedInvariants(info: Partial): asserts info is UnsignedV3DutchOrderInfo { + invariant(info.cosigner !== undefined, "cosigner not set"); + invariant(info.input !== undefined, "input not set"); + invariant( + info.outputs && info.outputs.length > 0, + "outputs not set" + ); + // Check if input curve is valid + invariant(info.input.curve.relativeAmounts.length === info.input.curve.relativeBlocks.length, "relativeBlocks and relativeAmounts length mismatch"); + invariant(this.isRelativeBlocksIncreasing(info.input.curve.relativeBlocks), "relativeBlocks not strictly increasing"); + // For each output's curve, we need to make sure relativeBlocks is strictly increasing + info.outputs.forEach((output) => { + invariant( + output.curve.relativeBlocks.length === output.curve.relativeAmounts.length, + "relativeBlocks and relativeAmounts length mismatch" + ); + // For each output's curve, we need to make sure relativeBlocks is strictly increasing + invariant(this.isRelativeBlocksIncreasing(output.curve.relativeBlocks), "relativeBlocks not strictly increasing"); + }); + // In V3, we don't have a decayEndTime field and use OrderInfo.deadline field for Permit2 + invariant(this.orderInfo.deadline !== undefined, "deadline not set"); + invariant(this.orderInfo.swapper !== undefined, "swapper not set"); + } + + private checkCosignedInvariants(info: Partial): asserts info is CosignedV3DutchOrderInfo { + // In V3, we are not enforcing that the startAmount is greater than the endAmount + invariant(info.cosignerData !== undefined, "cosignerData not set"); + invariant(info.cosignerData.decayStartBlock !== undefined, "decayStartBlock not set"); + invariant( + info.cosignerData.exclusiveFiller !== undefined, + "exclusiveFiller not set" + ); + invariant( + info.cosignerData.exclusivityOverrideBps !== undefined, + "exclusivityOverrideBps not set" + ); + invariant( + info.cosignerData.outputOverrides.length > 0, + "outputOverrides not set" + ); + invariant( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + info.cosignerData.inputOverride.lte(this.info.input!.startAmount), + "inputOverride larger than original input" + ); + info.cosignerData.outputOverrides.forEach((override, idx) => { + invariant( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + override.gte(this.info.outputs![idx].startAmount), + "outputOverride smaller than original output" + ); + }); + // We are not checking if the decayStartBlock is before the deadline because it is not enforced in the smart contract + } + + input(input: V3DutchInput): this { + this.info.input = input; + return this; + } + + output(output: V3DutchOutput): this { + this.info.outputs?.push(output); + return this; + } + + inputOverride(inputOverride: BigNumber): this { + if (!this.info.cosignerData) { + this.initializeCosignerData({ inputOverride }); + } else { + this.info.cosignerData.inputOverride = inputOverride; + } + return this; + } + + outputOverrides(outputOverrides: BigNumber[]): this { + if (!this.info.cosignerData) { + this.initializeCosignerData({ outputOverrides }); + } else { + this.info.cosignerData.outputOverrides = outputOverrides; + } + return this; + } + + deadline(deadline: number): this { + super.deadline(deadline); + return this; + } + + swapper(swapper: string): this { + super.swapper(swapper); + return this; + } + + nonce(nonce: BigNumber): this { + super.nonce(nonce); + return this; + } + + validation(info: ValidationInfo): this { + super.validation(info); + return this; + } + + cosignerData(cosignerData: V3CosignerData): this { + this.decayStartBlock(cosignerData.decayStartBlock); + this.exclusiveFiller(cosignerData.exclusiveFiller); + this.exclusivityOverrideBps(cosignerData.exclusivityOverrideBps); + this.inputOverride(cosignerData.inputOverride); + this.outputOverrides(cosignerData.outputOverrides); + return this; + } + + exclusiveFiller(exclusiveFiller: string): this { + if (!this.info.cosignerData) { + this.initializeCosignerData({ exclusiveFiller }); + } else { + this.info.cosignerData.exclusiveFiller = exclusiveFiller; + } + return this; + } + + exclusivityOverrideBps(exclusivityOverrideBps: BigNumber): this { + if (!this.info.cosignerData) { + this.initializeCosignerData({ exclusivityOverrideBps }); + } else { + this.info.cosignerData.exclusivityOverrideBps = exclusivityOverrideBps; + } + return this; + } + + // ensures that we only change non fee outputs + nonFeeRecipient(newRecipient: string, feeRecipient?: string): this { + invariant( + newRecipient !== feeRecipient, + `newRecipient must be different from feeRecipient: ${newRecipient}` + ); + if (!this.info.outputs) { + return this; + } + this.info.outputs = this.info.outputs.map((output) => { + // if fee output then pass through + if ( + feeRecipient && + output.recipient.toLowerCase() === feeRecipient.toLowerCase() + ) { + return output; + } + + return { + ...output, + recipient: newRecipient, + }; + }); + return this; + } + + buildPartial(): UnsignedV3DutchOrder { //build an unsigned order + this.checkUnsignedInvariants(this.info); + return new UnsignedV3DutchOrder( + Object.assign(this.getOrderInfo(), { + input: this.info.input, + outputs: this.info.outputs, + cosigner: this.info.cosigner, + }), + this.chainId, + this.permit2Address + ); + } + + // A helper function for users of the class to easily the value to pass to maxAmount in an input + static getMaxAmountOut(startAmount: BigNumber, relativeAmounts: bigint[]) : BigNumber { + if (relativeAmounts.length == 0) { + throw new Error("relativeAmounts cannot be empty"); + } + // Find the minimum of the relative amounts + const minRelativeAmount = relativeAmounts.reduce((min, amount) => amount < min ? amount : min, relativeAmounts[0]); + // Maximum is the start - the min of the relative amounts + const maxOut = startAmount.sub(minRelativeAmount.toString()); + return maxOut; + } +} \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/constants.test.ts b/sdks/uniswapx-sdk/src/constants.test.ts index 886f8309..ffd32958 100644 --- a/sdks/uniswapx-sdk/src/constants.test.ts +++ b/sdks/uniswapx-sdk/src/constants.test.ts @@ -28,6 +28,7 @@ describe("REACTOR_ADDRESS_MAPPING", () => { "42161": Object { "Dutch": "0x0000000000000000000000000000000000000000", "Dutch_V2": "0x1bd1aAdc9E230626C44a139d7E70d842749351eb", + "Dutch_V3": "0x0000000000000000000000000000000000000000", "Relay": "0x0000000000000000000000000000000000000000", }, "5": Object { diff --git a/sdks/uniswapx-sdk/src/constants.ts b/sdks/uniswapx-sdk/src/constants.ts index 50f7bc25..dbf1b54d 100644 --- a/sdks/uniswapx-sdk/src/constants.ts +++ b/sdks/uniswapx-sdk/src/constants.ts @@ -54,6 +54,7 @@ export enum OrderType { Dutch = "Dutch", Relay = "Relay", Dutch_V2 = "Dutch_V2", + Dutch_V3 = "Dutch_V3", Limit = "Limit", Priority = "Priority", } @@ -94,6 +95,7 @@ export const REACTOR_ADDRESS_MAPPING: ReactorMapping = { [OrderType.Dutch_V2]: "0x1bd1aAdc9E230626C44a139d7E70d842749351eb", [OrderType.Dutch]: "0x0000000000000000000000000000000000000000", [OrderType.Relay]: "0x0000000000000000000000000000000000000000", + [OrderType.Dutch_V3]: "0x0000000000000000000000000000000000000000", }, 8453: { [OrderType.Dutch]: "0x0000000000000000000000000000000000000000", diff --git a/sdks/uniswapx-sdk/src/order/V2DutchOrder.test.ts b/sdks/uniswapx-sdk/src/order/V2DutchOrder.test.ts index 738ee711..ac1589b1 100644 --- a/sdks/uniswapx-sdk/src/order/V2DutchOrder.test.ts +++ b/sdks/uniswapx-sdk/src/order/V2DutchOrder.test.ts @@ -245,6 +245,7 @@ describe("V2DutchOrder", () => { ); }); + //TODO: The tests below this line are not testing anything it("resolves when filler has exclusivity", () => { const exclusiveFiller = "0x0000000000000000000000000000000000000001"; const order = new CosignedV2DutchOrder( diff --git a/sdks/uniswapx-sdk/src/order/V2DutchOrder.ts b/sdks/uniswapx-sdk/src/order/V2DutchOrder.ts index 105047c2..ac9be1b2 100644 --- a/sdks/uniswapx-sdk/src/order/V2DutchOrder.ts +++ b/sdks/uniswapx-sdk/src/order/V2DutchOrder.ts @@ -10,9 +10,12 @@ import { BigNumber, ethers } from "ethers"; import { getPermit2 } from "../utils"; import { ResolvedUniswapXOrder } from "../utils/OrderQuoter"; import { getDecayedAmount } from "../utils/dutchDecay"; +import { originalIfZero } from "../utils/order"; import { BlockOverrides, + CosignerData, + CosignerDataJSON, DutchInput, DutchInputJSON, DutchOutput, @@ -23,24 +26,6 @@ import { } from "./types"; import { CustomOrderValidation, parseValidation } from "./validation"; -export type CosignerData = { - decayStartTime: number; - decayEndTime: number; - exclusiveFiller: string; - exclusivityOverrideBps: BigNumber; - inputOverride: BigNumber; - outputOverrides: BigNumber[]; -}; - -export type CosignerDataJSON = { - decayStartTime: number; - decayEndTime: number; - exclusiveFiller: string; - exclusivityOverrideBps: number; - inputOverride: string; - outputOverrides: string[]; -}; - export type UnsignedV2DutchOrderInfo = OrderInfo & { cosigner: string; input: DutchInput; @@ -557,10 +542,6 @@ export class CosignedV2DutchOrder extends UnsignedV2DutchOrder { } } -function originalIfZero(value: BigNumber, original: BigNumber): BigNumber { - return value.isZero() ? original : value; -} - function parseSerializedOrder(serialized: string): CosignedV2DutchOrderInfo { const abiCoder = new ethers.utils.AbiCoder(); const decoded = abiCoder.decode(V2_DUTCH_ORDER_ABI, serialized); diff --git a/sdks/uniswapx-sdk/src/order/V3DutchOrder.test.ts b/sdks/uniswapx-sdk/src/order/V3DutchOrder.test.ts new file mode 100644 index 00000000..f3e306f7 --- /dev/null +++ b/sdks/uniswapx-sdk/src/order/V3DutchOrder.test.ts @@ -0,0 +1,262 @@ +import { expect } from "chai"; +import { BigNumber, ethers } from "ethers"; + +import { getEndAmount } from "../utils/dutchBlockDecay"; + +import { CosignedV3DutchOrder, CosignedV3DutchOrderInfo, UnsignedV3DutchOrder, UnsignedV3DutchOrderInfoJSON } from "./V3DutchOrder"; + +const TIME= 1725379823; +const BLOCK_NUMBER = 20671221; +const RAW_AMOUNT = BigNumber.from("2121000"); +const INPUT_TOKEN = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const OUTPUT_TOKEN = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const CHAIN_ID = 1; + +const COSIGNER_DATA_WITH_OVERRIDES = { + decayStartBlock: BLOCK_NUMBER, + exclusiveFiller: ethers.constants.AddressZero, + exclusivityOverrideBps: BigNumber.from(0), + inputOverride: RAW_AMOUNT, + outputOverrides: [RAW_AMOUNT.mul(102).div(100)], +}; + +const COSIGNER_DATA_WITHOUT_OVERRIDES = { + decayStartBlock: BLOCK_NUMBER, + exclusiveFiller: ethers.constants.AddressZero, + exclusivityOverrideBps: BigNumber.from(0), + inputOverride: BigNumber.from(0), + outputOverrides: [BigNumber.from(0)], +}; + +describe("V3DutchOrder", () => { + const getFullOrderInfo = ( data: Partial): CosignedV3DutchOrderInfo => { + return Object.assign( + { + reactor: ethers.constants.AddressZero, + swapper: ethers.constants.AddressZero, + nonce: BigNumber.from(21), + deadline: TIME + 1000, + additionalValidationContract: ethers.constants.AddressZero, + additionalValidationData: "0x", + cosigner: ethers.constants.AddressZero, + cosignerData: COSIGNER_DATA_WITH_OVERRIDES, + input: { + token: INPUT_TOKEN, + startAmount: RAW_AMOUNT, + curve: { + relativeBlocks: [1], //TODO: can we have relativeblocks be an array of just 0 + relativeAmounts: [BigInt(0)], + }, + maxAmount: RAW_AMOUNT, //we don't want input to change, we're testing for decaying output + }, + outputs: [ + { + token: OUTPUT_TOKEN, + startAmount: RAW_AMOUNT, + curve: { + relativeBlocks: [1,2,3,4], + relativeAmounts: [BigInt(1), BigInt(2), BigInt(3), BigInt(4)], // 1e-18, 2e-18, 3e-18, 4e-18 + }, + recipient: ethers.constants.AddressZero, + }, + ], + cosignature: "0x", + }, + data + ); + }; + + const getFullOrderInfoWithoutOverrides: CosignedV3DutchOrderInfo = { + ...getFullOrderInfo({}), + cosignerData: COSIGNER_DATA_WITHOUT_OVERRIDES, + } + + it("Parses a serialized v3 order", () => { + const orderInfo = getFullOrderInfo({}); + const order = new CosignedV3DutchOrder(orderInfo, CHAIN_ID); + const seralized = order.serialize(); + const parsed = CosignedV3DutchOrder.parse(seralized, CHAIN_ID); + expect(parsed.info).to.deep.eq(orderInfo); + }); + + it("Parses a serialized v3 order with negative relativeAmounts", () => { + const orderInfo = getFullOrderInfo({ + outputs: [ + { + token: OUTPUT_TOKEN, + startAmount: RAW_AMOUNT, + curve: { + relativeBlocks: [1,2,3,4], + relativeAmounts: [BigInt(-1), BigInt(-2), BigInt(-3), BigInt(-4)], + }, + recipient: ethers.constants.AddressZero, + }, + ], + }); + const order = new CosignedV3DutchOrder(orderInfo, CHAIN_ID); + const seralized = order.serialize(); + const parsed = CosignedV3DutchOrder.parse(seralized, CHAIN_ID); + expect(parsed.info).to.deep.eq(orderInfo); + }); + + it("parses inner v3 order with no cosigner overrides, both input and output curves", () => { + const orderInfoJSON : UnsignedV3DutchOrderInfoJSON = { + ...getFullOrderInfo({}), + nonce: "21", + input: { + token: INPUT_TOKEN, + startAmount: "1000000", + curve: { + relativeBlocks: [1,2,3,4], + relativeAmounts: ["1", "2", "3", "4"], + }, + maxAmount: "1000001", + }, + outputs: [ + { + token: OUTPUT_TOKEN, + startAmount: "1000000", + curve: { + relativeBlocks: [1,2,3,4], + relativeAmounts: ["1", "2", "3", "4"], + }, + recipient: ethers.constants.AddressZero, + }, + ], + }; + const order = UnsignedV3DutchOrder.fromJSON(orderInfoJSON, CHAIN_ID); + expect(order.info.input.startAmount.toString()).to.equal("1000000"); + expect(order.info.outputs[0].startAmount.toString()).to.eq("1000000"); + }); + + it("valid signature over inner order", async () => { + const fullOrderInfo = getFullOrderInfo({}); + const order = new UnsignedV3DutchOrder(fullOrderInfo, 1); + const wallet = ethers.Wallet.createRandom(); + + const { domain, types, values } = order.permitData(); + const signature = await wallet._signTypedData(domain, types, values); + expect(order.getSigner(signature)).equal(await wallet.getAddress()); + const fullOrder = CosignedV3DutchOrder.fromUnsignedOrder( + order, + fullOrderInfo.cosignerData, + fullOrderInfo.cosignature + ); + expect(fullOrder.getSigner(signature)).equal(await wallet.getAddress()); + }); + + it("validates cosignature over (hash || cosignerData)", async () => { + const wallet = ethers.Wallet.createRandom(); + const orderInfo = getFullOrderInfo({ + cosigner: await wallet.getAddress(), + }); + const order = new UnsignedV3DutchOrder(orderInfo, 1); + const fullOrderHash = order.cosignatureHash(orderInfo.cosignerData); + const cosignature = await wallet.signMessage(fullOrderHash); + const signedOrder = CosignedV3DutchOrder.fromUnsignedOrder( + order, + COSIGNER_DATA_WITH_OVERRIDES, + cosignature + ); + + expect(signedOrder.recoverCosigner()).equal(await wallet.getAddress()); + }); + + describe("resolve DutchV3 orders", () => { + it("resolves before decayStartBlock", () => { + const order = new CosignedV3DutchOrder(getFullOrderInfo({}), CHAIN_ID); + const resolved = order.resolve({ + currentBlock: BLOCK_NUMBER - 1, //no decay yet + }); + expect(resolved.input.token).eq(order.info.input.token); + expect(resolved.input.amount).eq(order.info.cosignerData.inputOverride); + expect(resolved.outputs[0].token).eq(order.info.outputs[0].token); + expect(resolved.outputs[0].amount).eq( + order.info.cosignerData.outputOverrides[0] + ); + }); + + it("resolves with original value when overrides==0", () => { + const order = new CosignedV3DutchOrder( + getFullOrderInfo({ + cosignerData: COSIGNER_DATA_WITHOUT_OVERRIDES, + }), + CHAIN_ID + ); + const resolved = order.resolve({ + currentBlock: BLOCK_NUMBER - 1, //no decay yet + }); + expect(resolved.input.token).eq(order.info.input.token); + expect(resolved.input.amount).eq(order.info.input.startAmount); + expect(resolved.outputs[0].token).eq(order.info.outputs[0].token); + expect(resolved.outputs[0].amount).eq(order.info.outputs[0].startAmount); + }); + + it("resolves at decayStartBlock", () => { + const order = new CosignedV3DutchOrder(getFullOrderInfo({}), CHAIN_ID); + const resolved = order.resolve({ + currentBlock: BLOCK_NUMBER, + }); + expect(resolved.input.token).eq(order.info.input.token); + expect(resolved.input.amount).eq(order.info.cosignerData.inputOverride); + expect(resolved.outputs.length).eq(1); + expect(resolved.outputs[0].token).eq(order.info.outputs[0].token); + expect(resolved.outputs[0].amount).eq(order.info.cosignerData.outputOverrides[0]); + }); + + it("resolves at last decay block without overrides", () => { + const order = new CosignedV3DutchOrder(getFullOrderInfoWithoutOverrides, CHAIN_ID); + const relativeBlocks = order.info.outputs[0].curve.relativeBlocks; + const resolved = order.resolve({ + currentBlock: BLOCK_NUMBER + relativeBlocks[relativeBlocks.length - 1], + }); + expect(resolved.input.token).eq(order.info.input.token); + expect(resolved.outputs[0].token).eq(order.info.outputs[0].token); + const endAmount = getEndAmount({ + decayStartBlock: BLOCK_NUMBER, + startAmount: order.info.outputs[0].startAmount, + relativeBlocks: order.info.outputs[0].curve.relativeBlocks, + relativeAmounts: order.info.outputs[0].curve.relativeAmounts, + }); + expect(resolved.outputs[0].amount.toNumber()).eq(endAmount.toNumber()); + }); + + it("resolves after last decay without overrides", () => { + const order = new CosignedV3DutchOrder(getFullOrderInfoWithoutOverrides, CHAIN_ID); + const resolved = order.resolve({ + currentBlock: BLOCK_NUMBER + 42, + }); + expect(resolved.input.token).eq(order.info.input.token); + expect(resolved.outputs[0].token).eq(order.info.outputs[0].token); + const endAmount = getEndAmount({ + decayStartBlock: BLOCK_NUMBER, + startAmount: order.info.outputs[0].startAmount, + relativeBlocks: order.info.outputs[0].curve.relativeBlocks, + relativeAmounts: order.info.outputs[0].curve.relativeAmounts, + }); + expect(resolved.outputs[0].amount.toNumber()).eq(endAmount.toNumber()); //deep eq on bignumber failed + }); + //TODO: resolves for overrides + it("resolves when filler has exclusivity: Before Decay Start", () => { + const exclusiveFiller = ethers.Wallet.createRandom().address; + const order = new CosignedV3DutchOrder( + getFullOrderInfo({ + cosignerData: { + ...COSIGNER_DATA_WITH_OVERRIDES, + exclusiveFiller, + }, + }), + CHAIN_ID + ); + const resolved = order.resolve({ + currentBlock: BLOCK_NUMBER - 1, + filler: exclusiveFiller, + }); + expect(resolved.input.token).eq(order.info.input.token); + expect(resolved.input.amount).eq(order.info.cosignerData.inputOverride); + expect(resolved.outputs.length).eq(1); + expect(resolved.outputs[0].token).eq(order.info.outputs[0].token); + expect(resolved.outputs[0].amount).eq(order.info.cosignerData.outputOverrides[0]); + }); + }); +}); diff --git a/sdks/uniswapx-sdk/src/order/V3DutchOrder.ts b/sdks/uniswapx-sdk/src/order/V3DutchOrder.ts new file mode 100644 index 00000000..45823d5b --- /dev/null +++ b/sdks/uniswapx-sdk/src/order/V3DutchOrder.ts @@ -0,0 +1,581 @@ +import { SignatureLike } from "@ethersproject/bytes"; +import { PermitTransferFrom, PermitTransferFromData, SignatureTransfer, Witness } from "@uniswap/permit2-sdk"; +import { BigNumber, ethers } from "ethers"; + +import { getPermit2, ResolvedUniswapXOrder } from "../utils"; +import { getBlockDecayedAmount } from "../utils/dutchBlockDecay"; +import { originalIfZero } from "../utils/order"; + +import { BlockOverrides, CosignerData, CosignerDataJSON, OffChainOrder, OrderInfo, V3DutchInput, V3DutchInputJSON, V3DutchOutput, V3DutchOutputJSON, V3OrderResolutionOptions } from "./types"; + +export type V3CosignerDataJSON = Omit & { + decayStartBlock: number; +}; + +export type V3CosignerData = Omit & { + decayStartBlock: number; +}; + +export type UnsignedV3DutchOrderInfoJSON = Omit & { + nonce: string; + input: V3DutchInputJSON; + outputs: V3DutchOutputJSON[]; +}; + +export type UnsignedV3DutchOrderInfo = OrderInfo & { + cosigner: string; + input: V3DutchInput; //different from V2DutchOrder + outputs: V3DutchOutput[]; +}; + +export type CosignedV3DutchOrderInfoJSON = UnsignedV3DutchOrderInfoJSON & { + cosignerData: V3CosignerDataJSON; + cosignature: string; +}; + +export type CosignedV3DutchOrderInfo = UnsignedV3DutchOrderInfo & { + cosignerData: V3CosignerData; + cosignature: string; +}; + +type V3WitnessInfo = { + info: OrderInfo, + cosigner: string, + baseInput: V3DutchInput, + baseOutputs: V3DutchOutput[], +}; + +const COSIGNER_DATA_TUPLE_ABI = "tuple(uint256,address,uint256,uint256,uint256[])"; + +const V3_DUTCH_ORDER_TYPES = { + V3DutchOrder: [ + { name: "info", type: "OrderInfo" }, + { name: "cosigner", type: "address" }, + { name: "baseInput", type: "V3DutchInput" }, + { name: "baseOutputs", type: "V3DutchOutput[]" }, + ], + OrderInfo: [ + { name: "reactor", type: "address" }, + { name: "swapper", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "additionalValidationContract", type: "address" }, + { name: "additionalValidationData", type: "bytes" }, + ], + V3DutchInput: [ + { name: "token", type: "address" }, + { name: "startAmount", type: "uint256" }, + { name: "curve", type: "NonlinearDutchDecay" }, + { name: "maxAmount", type: "uint256" }, + ], + V3DutchOutput: [ + { name: "token", type: "address" }, + { name: "startAmount", type: "uint256" }, + { name: "curve", type: "NonlinearDutchDecay" }, + { name: "recipient", type: "address" }, + ], + NonlinearDutchDecay: [ + { name: "relativeBlocks", type: "uint256" }, + { name: "relativeAmounts", type: "int256[]" }, + ], +}; + +const V3_DUTCH_ORDER_ABI = [ + "tuple(" + + [ + "tuple(address,address,uint256,uint256,address,bytes)", // OrderInfo + "address", // Cosigner + "tuple(address,uint256,tuple(uint256,int256[]),uint256)", // V3DutchInput + "tuple(address,uint256,tuple(uint256,int256[]),address)[]", // V3DutchOutput + COSIGNER_DATA_TUPLE_ABI, + "bytes", // Cosignature + ].join(",") + ")", +]; + +export class UnsignedV3DutchOrder implements OffChainOrder { + public permit2Address: string; + + constructor ( + public readonly info: UnsignedV3DutchOrderInfo, + public readonly chainId: number, + _permit2Address?: string + ) { + this.permit2Address = getPermit2(chainId, _permit2Address); + } + + static fromJSON( + json: UnsignedV3DutchOrderInfoJSON, + chainId: number, + _permit2Address?: string + ): UnsignedV3DutchOrder { + return new UnsignedV3DutchOrder( + { + ...json, + nonce: BigNumber.from(json.nonce), + input: { + ...json.input, + startAmount: BigNumber.from(json.input.startAmount), + curve: { + relativeBlocks: json.input.curve.relativeBlocks, + relativeAmounts: json.input.curve.relativeAmounts.map(amount => BigInt(amount)), + }, + maxAmount: BigNumber.from(json.input.maxAmount), + }, + outputs: json.outputs.map(output => ({ + ...output, + startAmount: BigNumber.from(output.startAmount), + curve: { + relativeBlocks: output.curve.relativeBlocks, + relativeAmounts: output.curve.relativeAmounts.map(amount => BigInt(amount)), + }, + })), + }, + chainId, + _permit2Address + ); + } + + /** + * @inheritdoc order + */ + get blockOverrides(): BlockOverrides { + return undefined + } + + /** + * @inheritdoc order + */ + serialize(): string { + const encodedRelativeBlocks = encodeRelativeBlocks(this.info.input.curve.relativeBlocks); + const abiCoder = new ethers.utils.AbiCoder(); + return abiCoder.encode(V3_DUTCH_ORDER_ABI, [ + [ + [ + this.info.reactor, + this.info.swapper, + this.info.nonce, + this.info.deadline, + this.info.additionalValidationContract, + this.info.additionalValidationData, + ], + this.info.cosigner, + [ + this.info.input.token, + this.info.input.startAmount, + [encodedRelativeBlocks, this.info.input.curve.relativeAmounts], + this.info.input.maxAmount, + ], + this.info.outputs.map(output => [ + output.token, + output.startAmount, + [encodedRelativeBlocks, output.curve.relativeAmounts], + output.recipient, + ]), + [0, ethers.constants.AddressZero, 0, 0, [0]], + "0x", + ], + ]); + } + + /** + * @inheritdoc order + */ + toJSON(): UnsignedV3DutchOrderInfoJSON & { + permit2Address: string; + chainId: number; + } { + return { + reactor: this.info.reactor, + swapper: this.info.swapper, + nonce: this.info.nonce.toString(), + deadline: this.info.deadline, + additionalValidationContract: this.info.additionalValidationContract, + additionalValidationData: this.info.additionalValidationData, + cosigner: this.info.cosigner, + input: { + token: this.info.input.token, + startAmount: this.info.input.startAmount.toString(), + curve: { + relativeBlocks: this.info.input.curve.relativeBlocks, + relativeAmounts: this.info.input.curve.relativeAmounts.map(amount => amount.toString()), + }, + maxAmount: this.info.input.maxAmount.toString(), + }, + outputs: this.info.outputs.map(output => ({ + token: output.token, + startAmount: output.startAmount.toString(), + curve: { + relativeBlocks: output.curve.relativeBlocks, + relativeAmounts: output.curve.relativeAmounts.map(amount => amount.toString()), + }, + recipient: output.recipient, + })), + chainId: this.chainId, + permit2Address: this.permit2Address, + } + }; + + permitData(): PermitTransferFromData { + return SignatureTransfer.getPermitData( + this.toPermit(), + this.permit2Address, + this.chainId, + this.witness(), + ) as PermitTransferFromData; + } + + private toPermit(): PermitTransferFrom { + return { + permitted: { + token: this.info.input.token, + amount: this.info.input.maxAmount, + }, + spender: this.info.reactor, + nonce: this.info.nonce, + deadline: this.info.deadline, + } + } + + private witnessInfo(): V3WitnessInfo { + return { + info: { + reactor: this.info.reactor, + swapper: this.info.swapper, + nonce: this.info.nonce, + deadline: this.info.deadline, + additionalValidationContract: this.info.additionalValidationContract, + additionalValidationData: this.info.additionalValidationData, + }, + cosigner: this.info.cosigner, + baseInput: this.info.input, + baseOutputs: this.info.outputs, + } + } + + private witness(): Witness { + return { + witness: this.witnessInfo(), + witnessTypeName: "V3DutchOrder", + witnessType: V3_DUTCH_ORDER_TYPES, + }; + } + + getSigner(signature: SignatureLike): string { + return ethers.utils.computeAddress( + ethers.utils.recoverPublicKey( + SignatureTransfer.hash( + this.toPermit(), + this.permit2Address, + this.chainId, + this.witness(), + ), + signature + ) + ); + } + + hash(): string { + return ethers.utils._TypedDataEncoder + .from(V3_DUTCH_ORDER_TYPES) + .hash(this.witnessInfo()); + } + + cosignatureHash(cosignerData: V3CosignerData): string { + const abiCoder = new ethers.utils.AbiCoder(); + return ethers.utils.solidityKeccak256( + ["bytes32", "bytes"], + [ + this.hash(), + abiCoder.encode( + [COSIGNER_DATA_TUPLE_ABI], + [ + [ + cosignerData.decayStartBlock, + cosignerData.exclusiveFiller, + cosignerData.exclusivityOverrideBps, + cosignerData.inputOverride, + cosignerData.outputOverrides, + ], + ], + ), + ] + ) + } + + static parse( + encoded: string, + chainId: number, + permit2?: string + ): UnsignedV3DutchOrder { + return new UnsignedV3DutchOrder( + parseSerializedOrder(encoded), + chainId, + permit2 + ); + } + +} + +export class CosignedV3DutchOrder extends UnsignedV3DutchOrder { + static fromUnsignedOrder( + order: UnsignedV3DutchOrder, + cosignerData: V3CosignerData, + cosignature: string + ): CosignedV3DutchOrder { + return new CosignedV3DutchOrder( + { + ...order.info, + cosignerData, + cosignature, + }, + order.chainId, + order.permit2Address + ); + } + + static fromJSON( + json: CosignedV3DutchOrderInfoJSON, + chainId: number, + _permit2Address?: string + ): CosignedV3DutchOrder { + return new CosignedV3DutchOrder( + { + ...json, + nonce: BigNumber.from(json.nonce), + input: { + token: json.input.token, + startAmount: BigNumber.from(json.input.startAmount), + curve: { + relativeBlocks: json.input.curve.relativeBlocks, + relativeAmounts: json.input.curve.relativeAmounts.map(amount => BigInt(amount)), + }, + maxAmount: BigNumber.from(json.input.maxAmount), + }, + outputs: json.outputs.map(output => ({ + token: output.token, + startAmount: BigNumber.from(output.startAmount), + curve: { + relativeBlocks: output.curve.relativeBlocks, + relativeAmounts: output.curve.relativeAmounts.map(amount => BigInt(amount)), + }, + recipient: output.recipient, + })), + cosignerData: { + decayStartBlock: json.cosignerData.decayStartBlock, + exclusiveFiller: json.cosignerData.exclusiveFiller, + exclusivityOverrideBps: BigNumber.from( + json.cosignerData.exclusivityOverrideBps + ), + inputOverride: BigNumber.from(json.cosignerData.inputOverride), + outputOverrides: json.cosignerData.outputOverrides.map(BigNumber.from), + }, + cosignature: json.cosignature, + }, + chainId, + _permit2Address + ); + } + + constructor( + public readonly info: CosignedV3DutchOrderInfo, + public readonly chainId: number, + _permit2Address?: string + ) { + super(info, chainId, _permit2Address); + } + + /** + * @inheritdoc order + */ + toJSON(): CosignedV3DutchOrderInfoJSON & { + permit2Address: string; + chainId: number; + } { + return { + ...super.toJSON(), + cosignerData: { + decayStartBlock: this.info.cosignerData.decayStartBlock, + exclusiveFiller: this.info.cosignerData.exclusiveFiller, + exclusivityOverrideBps: this.info.cosignerData.exclusivityOverrideBps.toNumber(), + inputOverride: this.info.cosignerData.inputOverride.toString(), + outputOverrides: this.info.cosignerData.outputOverrides.map(override => override.toString()), + }, + cosignature: this.info.cosignature, + }; + } + + static parse( + encoded: string, + chainId: number, + permit2?: string + ): CosignedV3DutchOrder { + return new CosignedV3DutchOrder( + parseSerializedOrder(encoded), + chainId, + permit2 + ); + } + + serialize(): string { + const encodedInputRelativeBlocks = encodeRelativeBlocks(this.info.input.curve.relativeBlocks); + const abiCoder = new ethers.utils.AbiCoder(); + return abiCoder.encode(V3_DUTCH_ORDER_ABI, [ + [ + [ + this.info.reactor, + this.info.swapper, + this.info.nonce, + this.info.deadline, + this.info.additionalValidationContract, + this.info.additionalValidationData, + ], + this.info.cosigner, + [ + this.info.input.token, + this.info.input.startAmount, + [encodedInputRelativeBlocks, this.info.input.curve.relativeAmounts], + this.info.input.maxAmount, + ], + this.info.outputs.map(output => [ + output.token, + output.startAmount, + [encodeRelativeBlocks(output.curve.relativeBlocks), output.curve.relativeAmounts], + output.recipient, + ]), + [ + this.info.cosignerData.decayStartBlock, + this.info.cosignerData.exclusiveFiller, + this.info.cosignerData.exclusivityOverrideBps, + this.info.cosignerData.inputOverride.toString(), + this.info.cosignerData.outputOverrides.map(override => override.toString()), + ], + this.info.cosignature, + ], + ]); + } + + recoverCosigner(): string { + return ethers.utils.verifyMessage( + this.cosignatureHash(this.info.cosignerData), + this.info.cosignature + ); + } + + resolve(options: V3OrderResolutionOptions): ResolvedUniswapXOrder { + return { + input: { + token: this.info.input.token, + amount: getBlockDecayedAmount( + { + decayStartBlock: this.info.cosignerData.decayStartBlock, + startAmount: originalIfZero(this.info.cosignerData.inputOverride, this.info.input.startAmount), + relativeBlocks: this.info.input.curve.relativeBlocks, + relativeAmounts: this.info.input.curve.relativeAmounts, + }, + options.currentBlock + ), + }, + outputs: this.info.outputs.map((output, idx) => { + return { + token: output.token, + amount: getBlockDecayedAmount( + { + decayStartBlock: this.info.cosignerData.decayStartBlock, + startAmount: originalIfZero(this.info.cosignerData.outputOverrides[idx], output.startAmount), + relativeBlocks: output.curve.relativeBlocks, + relativeAmounts: output.curve.relativeAmounts, + }, + options.currentBlock + ), + } + }), + }; + } +} + +function parseSerializedOrder(serialized: string): CosignedV3DutchOrderInfo { + const abiCoder = new ethers.utils.AbiCoder(); + const decoded = abiCoder.decode(V3_DUTCH_ORDER_ABI, serialized); + const [ + [ + [ + reactor, + swapper, + nonce, + deadline, + additionalValidationContract, + additionalValidationData, + ], + cosigner, + [ + token, + startAmount, + [inputRelativeBlocks, relativeAmounts], + maxAmount, + ], + outputs, + [decayStartBlock, exclusiveFiller, exclusivityOverrideBps, inputOverride, outputOverrides], + cosignature, + ], + ] = decoded; + + return { + reactor, + swapper, + nonce, + deadline: deadline.toNumber(), + additionalValidationContract, + additionalValidationData, + cosigner, + input: { + token, + startAmount, + curve: { + relativeBlocks: decodeRelativeBlocks(inputRelativeBlocks, relativeAmounts.length), + relativeAmounts: relativeAmounts.map((amount: BigNumber) => amount.toBigInt()), + }, + maxAmount, + }, + outputs: outputs.map( + ([token, startAmount, [outputRelativeBlocks, relativeAmounts], recipient]: [ + string, + number, + [BigNumber, BigNumber[]], //abiDecode automatically converts to BigNumber + string, + ]) => ({ + token, + startAmount, + curve: { + relativeBlocks: decodeRelativeBlocks(outputRelativeBlocks, relativeAmounts.length), + relativeAmounts: relativeAmounts.map((amount: BigNumber) => amount.toBigInt()), + }, + recipient, + }) + ), + cosignerData: { + decayStartBlock: decayStartBlock.toNumber(), + exclusiveFiller, + exclusivityOverrideBps: exclusivityOverrideBps, + inputOverride: inputOverride, + outputOverrides, + }, + cosignature, + }; +} + +function encodeRelativeBlocks(relativeBlocks: number[]): BigNumber { + let packedData = BigNumber.from(0); + for (let i = 0; i < relativeBlocks.length; i++) { + packedData = packedData.or(BigNumber.from(relativeBlocks[i]).shl(i * 16)); + } + return packedData; +} + +function decodeRelativeBlocks(packedData: BigNumber, relativeAmountsLength: number): number[] { + const relativeBlocks: number[] = []; + for (let i = 0; i < relativeAmountsLength; i++) { + const block = packedData.shr(i * 16).toNumber() & 0xFFFF; + relativeBlocks.push(block); + } + return relativeBlocks; +} \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/order/index.ts b/sdks/uniswapx-sdk/src/order/index.ts index 310ae522..899c53a3 100644 --- a/sdks/uniswapx-sdk/src/order/index.ts +++ b/sdks/uniswapx-sdk/src/order/index.ts @@ -2,6 +2,7 @@ import { DutchOrder } from "./DutchOrder"; import { CosignedPriorityOrder, UnsignedPriorityOrder } from "./PriorityOrder"; import { RelayOrder } from "./RelayOrder"; import { CosignedV2DutchOrder, UnsignedV2DutchOrder } from "./V2DutchOrder"; +import { CosignedV3DutchOrder, UnsignedV3DutchOrder } from "./V3DutchOrder"; export * from "./DutchOrder"; export * from "./PriorityOrder"; @@ -9,12 +10,15 @@ export * from "./RelayOrder"; export * from "./types"; export * from "./validation"; export * from "./V2DutchOrder"; +// TODO: To make V3 exports cleaner, export here but resolve ambiguous names vs V2 export type UniswapXOrder = | DutchOrder | UnsignedV2DutchOrder | CosignedV2DutchOrder + | UnsignedV3DutchOrder + | CosignedV3DutchOrder | UnsignedPriorityOrder | CosignedPriorityOrder; -export type Order = UniswapXOrder | RelayOrder; +export type Order = UniswapXOrder | RelayOrder; \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/order/types.ts b/sdks/uniswapx-sdk/src/order/types.ts index 7e497ea5..05528362 100644 --- a/sdks/uniswapx-sdk/src/order/types.ts +++ b/sdks/uniswapx-sdk/src/order/types.ts @@ -70,6 +70,11 @@ export type PriorityOrderResolutionOptions = { currentBlock?: BigNumber; }; +export type V3OrderResolutionOptions = { + currentBlock: number; + filler?: string; +} + export type DutchOutput = { readonly token: string; readonly startAmount: BigNumber; @@ -93,6 +98,24 @@ export type DutchInputJSON = Omit & { endAmount: string; }; +export type CosignerData = { + decayStartTime: number; + decayEndTime: number; + exclusiveFiller: string; + exclusivityOverrideBps: BigNumber; + inputOverride: BigNumber; + outputOverrides: BigNumber[]; +}; + +export type CosignerDataJSON = { + decayStartTime: number; + decayEndTime: number; + exclusiveFiller: string; + exclusivityOverrideBps: number; + inputOverride: string; + outputOverrides: string[]; +}; + export type PriorityInput = { readonly token: string; readonly amount: BigNumber; @@ -114,3 +137,38 @@ export type PriorityInputJSON = Omit< export type PriorityOutputJSON = PriorityInputJSON & { recipient: string; }; + +export type V3DutchInput = { + readonly token: string; + readonly startAmount: BigNumber; + readonly curve: NonlinearDutchDecay; + readonly maxAmount: BigNumber; +}; + +export type V3DutchInputJSON = Omit & { + startAmount: string; + curve: NonlinearDutchDecayJSON; + maxAmount: string; +}; + +export type NonlinearDutchDecay = { + relativeBlocks: number[]; + relativeAmounts: bigint[]; // Cannot be BigNumber because could be negative +}; + +export type NonlinearDutchDecayJSON = { + relativeBlocks: number[]; + relativeAmounts: string[]; +}; + +export type V3DutchOutput = { + readonly token: string; + readonly startAmount: BigNumber; + readonly curve: NonlinearDutchDecay; + readonly recipient: string; +}; + +export type V3DutchOutputJSON = Omit & { + startAmount: string; + curve: NonlinearDutchDecayJSON; +}; \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/trade/V3DutchOrderTrade.test.ts b/sdks/uniswapx-sdk/src/trade/V3DutchOrderTrade.test.ts new file mode 100644 index 00000000..f8ae8d99 --- /dev/null +++ b/sdks/uniswapx-sdk/src/trade/V3DutchOrderTrade.test.ts @@ -0,0 +1,198 @@ +import { Currency, Ether, Token, TradeType } from "@uniswap/sdk-core"; +import { BigNumber, constants, ethers } from "ethers"; + +import { UnsignedV3DutchOrderInfo } from "../order/V3DutchOrder"; + +import { V3DutchOrderTrade } from "./V3DutchOrderTrade"; +import { NativeAssets } from "./utils"; + +const USDC = new Token( + 1, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + 6, + "USDC" +); +const DAI = new Token( + 1, + "0x6B175474E89094C44Da98b954EedeAC495271d0F", + 18, + "DAI" +); + +describe("V3DutchOrderTrade", () => { + const NON_FEE_OUTPUT_AMOUNT = BigNumber.from("1000000000000000000"); + const NON_FEE_MINIMUM_AMOUNT_OUT = BigNumber.from("900000000000000000"); + + const orderInfo: UnsignedV3DutchOrderInfo = { + deadline: Math.floor(new Date().getTime() / 1000) + 1000, + reactor: "0x0000000000000000000000000000000000000000", + swapper: "0x0000000000000000000000000000000000000000", + nonce: BigNumber.from(10), + cosigner: "0x0000000000000000000000000000000000000000", + additionalValidationContract: ethers.constants.AddressZero, + additionalValidationData: "0x", + input: { + token: USDC.address, + startAmount: BigNumber.from(1000), + curve: { + relativeBlocks: [1], + relativeAmounts: [BigInt(0)], + }, + maxAmount: BigNumber.from(1000), + }, + outputs: [ + { + token: DAI.address, + startAmount: NON_FEE_OUTPUT_AMOUNT, + curve: { + relativeBlocks: [21], + relativeAmounts: [BigInt("100000000000000000")], + }, + recipient: "0x0000000000000000000000000000000000000000", + }, + { + token: DAI.address, + startAmount: BigNumber.from("1000"), + curve: { + relativeBlocks: [21], + relativeAmounts: [BigInt("100")], + }, + recipient: "0x0000000000000000000000000000000000000000", + }, + ], + }; + + const trade = new V3DutchOrderTrade({ + currencyIn: USDC, + currenciesOut: [DAI], + orderInfo, + tradeType: TradeType.EXACT_INPUT, + }); + + describe("Exact input", () => { + it("returns the right input amount for an exact-in trade", () => { + expect(trade.inputAmount.quotient.toString()).toEqual( + orderInfo.input.startAmount.toString() + ); + }); + + it("returns the correct non-fee output amount", () => { + expect(trade.outputAmount.quotient.toString()).toEqual( + NON_FEE_OUTPUT_AMOUNT.toString() + ); + }); + + it("returns the correct minimum amount out", () => { + expect(trade.minimumAmountOut().quotient.toString()).toEqual( + NON_FEE_MINIMUM_AMOUNT_OUT.toString() + ); + }); + }); + + describe("Exact output", () => { + const outOrderInfo: UnsignedV3DutchOrderInfo = { + deadline: Math.floor(new Date().getTime() / 1000) + 1000, + reactor: "0x0000000000000000000000000000000000000000", + swapper: "0x0000000000000000000000000000000000000000", + nonce: BigNumber.from(10), + cosigner: "0x0000000000000000000000000000000000000000", + additionalValidationContract: ethers.constants.AddressZero, + additionalValidationData: "0x", + input: { + token: USDC.address, + startAmount: BigNumber.from(1000), + curve: { + relativeBlocks: [10], + relativeAmounts: [BigInt(-100)], + }, + maxAmount: BigNumber.from(1100), + }, + outputs: [ + { + token: DAI.address, + startAmount: NON_FEE_OUTPUT_AMOUNT, + curve: { + relativeBlocks: [10], + relativeAmounts: [BigInt(0)], + }, + recipient: "0x0000000000000000000000000000000000000000", + }, + { + token: DAI.address, + startAmount: BigNumber.from("1000"), + curve: { + relativeBlocks: [10], + relativeAmounts: [BigInt(0)], + }, + recipient: "0x0000000000000000000000000000000000000000", + }, + ], + }; + const trade = new V3DutchOrderTrade({ + currencyIn: USDC, + currenciesOut: [DAI], + orderInfo: outOrderInfo, + tradeType: TradeType.EXACT_OUTPUT, + }); + + it("returns the correct maximum amount in", () => { + expect(trade.maximumAmountIn().quotient.toString()).toEqual( + outOrderInfo.input.maxAmount.toString() + ); + }); + }); + + describe("Qualitative tests", () => { + it("works for native output trades", () => { + const ethOutputOrderInfo = { + ...orderInfo, + outputs: [ + { + token: NativeAssets.ETH, + startAmount: NON_FEE_OUTPUT_AMOUNT, + curve: { + relativeBlocks: [21], + relativeAmounts: [BigInt("100000000000000000")], + }, + recipient: "0x0000000000000000000000000000000000000000", + }, + ], + }; + const ethOutputTrade = new V3DutchOrderTrade( + { + currencyIn: USDC, + currenciesOut: [Ether.onChain(1)], + orderInfo: ethOutputOrderInfo, + tradeType: TradeType.EXACT_INPUT, + } + ); + expect(ethOutputTrade.outputAmount.currency).toEqual(Ether.onChain(1)); + }); + + it("works for native output trades where order info has 0 address", () => { + const ethOutputOrderInfo = { + ...orderInfo, + outputs: [ + { + token: constants.AddressZero, + startAmount: NON_FEE_OUTPUT_AMOUNT, + curve: { + relativeBlocks: [21], + relativeAmounts: [BigInt("100000000000000000")], + }, + recipient: "0x0000000000000000000000000000000000000000", + }, + ], + }; + const ethOutputTrade = new V3DutchOrderTrade( + { + currencyIn: USDC, + currenciesOut: [Ether.onChain(1)], + orderInfo: ethOutputOrderInfo, + tradeType: TradeType.EXACT_INPUT, + } + ); + expect(ethOutputTrade.outputAmount.currency).toEqual(Ether.onChain(1)); + }); + }); +}); \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/trade/V3DutchOrderTrade.ts b/sdks/uniswapx-sdk/src/trade/V3DutchOrderTrade.ts new file mode 100644 index 00000000..74789f18 --- /dev/null +++ b/sdks/uniswapx-sdk/src/trade/V3DutchOrderTrade.ts @@ -0,0 +1,126 @@ +import { Currency, CurrencyAmount, Price, TradeType } from "@uniswap/sdk-core"; +import { BigNumber } from "ethers"; + +import { V3DutchOutput } from "../order"; +import { UnsignedV3DutchOrder, UnsignedV3DutchOrderInfo } from "../order/V3DutchOrder"; + +import { areCurrenciesEqual } from "./utils"; + +export class V3DutchOrderTrade< + TInput extends Currency, + TOutput extends Currency, + TTradeType extends TradeType +> { + public readonly tradeType: TTradeType + public readonly order: UnsignedV3DutchOrder + + private _inputAmount: CurrencyAmount | undefined + private _outputAmounts: CurrencyAmount[] | undefined + + private _currencyIn: TInput + private _currenciesOut: TOutput[] + + public constructor({ + currencyIn, + currenciesOut, + orderInfo, + tradeType, + }: { + currencyIn: TInput + currenciesOut: TOutput[] + orderInfo: UnsignedV3DutchOrderInfo + tradeType: TTradeType + }) { + this._currencyIn = currencyIn + this._currenciesOut = currenciesOut + this.tradeType = tradeType + + // Assuming not cross-chain + this.order = new UnsignedV3DutchOrder(orderInfo, currencyIn.chainId) + } + + public get inputAmount(): CurrencyAmount { + if (this._inputAmount) return this._inputAmount + + const amount = CurrencyAmount.fromRawAmount( + this._currencyIn, + this.order.info.input.startAmount.toString() + ) + this._inputAmount = amount + return amount + } + + public get outputAmounts(): CurrencyAmount[] { + if (this._outputAmounts) return this._outputAmounts + + const amounts = this.order.info.outputs.map((output) => { + // Assuming all outputs on the same chain + const currencyOut = this._currenciesOut.find((currency) => + areCurrenciesEqual(currency, output.token, currency.chainId) + ) + + if (!currencyOut) { + throw new Error("Currency out not found") + } + + return CurrencyAmount.fromRawAmount(currencyOut, output.startAmount.toString()) + }) + + this._outputAmounts = amounts + return amounts + } + + // Same assumption as V2 that there is only one non-fee output at a time, and it exists at index 0 + public get outputAmount(): CurrencyAmount { + return this.outputAmounts[0]; + } + + public minimumAmountOut(): CurrencyAmount { + const nonFeeOutput: V3DutchOutput = this.order.info.outputs[0]; + const relativeAmounts: bigint[] = nonFeeOutput.curve.relativeAmounts; + const startAmount: BigNumber = nonFeeOutput.startAmount; + // Get the maximum of the relative amounts + const maxRelativeAmount = relativeAmounts.reduce((max, amount) => amount > max ? amount : max, BigInt(0)); + // minimum is the start - the max of the relative amounts + const minOut = startAmount.sub(maxRelativeAmount.toString()); + return CurrencyAmount.fromRawAmount(this.outputAmount.currency, minOut.toString()); + } + + public maximumAmountIn(): CurrencyAmount { + const maxAmountIn = this.order.info.input.maxAmount; + return CurrencyAmount.fromRawAmount( + this._currencyIn, + maxAmountIn.toString() + ); + } + + private _executionPrice: Price | undefined; + + /** + * The price expressed in terms of output amount/input amount. + */ + public get executionPrice(): Price { + return ( + this._executionPrice ?? + (this._executionPrice = new Price( + this.inputAmount.currency, + this.outputAmount.currency, + this.inputAmount.quotient, + this.outputAmount.quotient + )) + ); + } + + /** + * Return the execution price after accounting for slippage tolerance + * @returns The execution price + */ + public worstExecutionPrice(): Price { + return new Price( + this.inputAmount.currency, + this.outputAmount.currency, + this.maximumAmountIn().quotient, + this.minimumAmountOut().quotient + ); + } +} \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/utils/dutchBlockDecay.test.ts b/sdks/uniswapx-sdk/src/utils/dutchBlockDecay.test.ts new file mode 100644 index 00000000..3014ef20 --- /dev/null +++ b/sdks/uniswapx-sdk/src/utils/dutchBlockDecay.test.ts @@ -0,0 +1,103 @@ +import { BigNumber } from "ethers"; + +import { NonLinearDutchDecayLib } from "./dutchBlockDecay"; + +describe("NonLinearDutchDecayLib", () => { + describe("linearDecay", () => { + it("Simple linearDecay", () => { + const result = NonLinearDutchDecayLib.linearDecay(0, 10, 5, BigNumber.from(100), BigNumber.from(50)); + expect(result.toString()).toEqual('75'); + }); + + it("Test for mulDivDown for endAmount < startAmount", () => { + const result = NonLinearDutchDecayLib.linearDecay(0, 10, 5, BigNumber.from(100), BigNumber.from(75)); + expect(result.toString()).toEqual('88'); //If we successfully emulated mulDivDown then this is 88. + }); + + it("Simple linearDecay but with endAmount > startAmount", () => { + const result = NonLinearDutchDecayLib.linearDecay(0, 10, 5, BigNumber.from(100), BigNumber.from(120)); + expect(result.toString()).toEqual('110'); + }); + + it("Test for mulDivDown for endAmount > startAmount", () => { + const result = NonLinearDutchDecayLib.linearDecay(0, 10, 5, BigNumber.from(100), BigNumber.from(125)); + //if we successfully emulated mulDivDown then this is 112 + expect(result.toString()).toEqual('112'); + }); + }); + + describe("decay", () => { + it("Returns startAmount if decay hasnt started", () => { + const test_payload = { + curve: { + relativeBlocks: [1, 2, 3, 4, 5], + relativeAmounts: [0, 10, 20, 30, 40].map(BigInt), + }, + startAmount: BigNumber.from(100), + decayStartBlock: 0, + currentBlock: 0, + }; + const result = NonLinearDutchDecayLib.decay(test_payload.curve, test_payload.startAmount, test_payload.decayStartBlock, test_payload.currentBlock); + expect(result.toString()).toEqual('100'); + }); + + it("Correctly calculates non-rounding decay", () => { + const test_payload = { + curve: { + relativeBlocks: [4], + relativeAmounts: [40].map(BigInt), + }, + startAmount: BigNumber.from(100), + decayStartBlock: 0, + currentBlock: 2, + }; + const result = NonLinearDutchDecayLib.decay(test_payload.curve, test_payload.startAmount, test_payload.decayStartBlock, test_payload.currentBlock); + expect(result.toString()).toEqual('80'); + }); + + // Add a rounding decay once we clarify mulDivDown/mulDivUp + + it("Correctly calculates non-rounding decay with multiple points", () => { + const test_payload = { + curve: { + relativeBlocks: [4, 6], + relativeAmounts: [40, 20].map(BigInt), + }, + startAmount: BigNumber.from(100), + decayStartBlock: 0, + currentBlock: 5, + }; + const result = NonLinearDutchDecayLib.decay(test_payload.curve, test_payload.startAmount, test_payload.decayStartBlock, test_payload.currentBlock); + expect(result.toString()).toEqual('70'); + }); + + it("Correctly calculates non-rounding negative decay", () => { + const test_payload = { + curve: { + relativeBlocks: [4], + relativeAmounts: [BigInt(-40)], + }, + startAmount: BigNumber.from(100), + decayStartBlock: 0, + currentBlock: 2, + }; + const result = NonLinearDutchDecayLib.decay(test_payload.curve, test_payload.startAmount, test_payload.decayStartBlock, test_payload.currentBlock); + expect(result.toString()).toEqual('120'); + }); + + it("Correctly calculates non-rounding negative decay with multiple points", () => { + const test_payload = { + curve: { + relativeBlocks: [4, 6], + relativeAmounts: [BigInt(-40), BigInt(-20)], + }, + startAmount: BigNumber.from(100), + decayStartBlock: 0, + currentBlock: 5, + }; + const result = NonLinearDutchDecayLib.decay(test_payload.curve, test_payload.startAmount, test_payload.decayStartBlock, test_payload.currentBlock); + expect(result.toString()).toEqual('130'); + }); + + }); +}); \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/utils/dutchBlockDecay.ts b/sdks/uniswapx-sdk/src/utils/dutchBlockDecay.ts new file mode 100644 index 00000000..7f74405a --- /dev/null +++ b/sdks/uniswapx-sdk/src/utils/dutchBlockDecay.ts @@ -0,0 +1,124 @@ +import { BigNumber } from "ethers"; + +import { NonlinearDutchDecay } from "../order"; +/* +These functions mimic the smart contract functions as closely as possible to ensure that the same results are produced. +Essentially Solidity translated to TypeScript. +*/ +function locateArrayPosition( + curve: NonlinearDutchDecay, + currentRelativeBlock: number +): [number, number] { + const relativeBlocks = curve.relativeBlocks; + let prev = 0; + let next = 0; + for (; next < relativeBlocks.length; next++) { + if(relativeBlocks[next] >= currentRelativeBlock) { + return [prev, next]; + } + prev = next; + } + return [next - 1, next - 1]; +} + +class NonLinearDutchDecayLib { + static decay( + curve: NonlinearDutchDecay, + startAmount: BigNumber, + decayStartBlock: number, + currentBlock: number + ): BigNumber { + // mismatch of relativeAmounts and relativeBlocks + if (curve.relativeAmounts.length > 16) { + throw new Error("InvalidDecayCurve"); + } + + // handle current block before decay or no decay + if (decayStartBlock >= currentBlock || curve.relativeAmounts.length === 0) { + return startAmount; + } + + const blockDelta = currentBlock - decayStartBlock; + + // Special case for when we need to use the decayStartBlock (0) + if (curve.relativeBlocks[0] > blockDelta) { + return this.linearDecay( + 0, + curve.relativeBlocks[0], + blockDelta, + startAmount, + startAmount.sub(curve.relativeAmounts[0].toString()) + ); + } + + // the current pos is within or after the curve + const [prev, next] = locateArrayPosition(curve, blockDelta); + //relativeAmounts holds BigInts so we can't directly subtract without conversion + const lastAmount = startAmount.sub(curve.relativeAmounts[prev].toString()); + const nextAmount = startAmount.sub(curve.relativeAmounts[next].toString()); + return this.linearDecay( + curve.relativeBlocks[prev], + curve.relativeBlocks[next], + blockDelta, + lastAmount, + nextAmount + ); + } + + static linearDecay( + startPoint: number, + endPoint: number, + currentPoint: number, + startAmount: BigNumber, + endAmount: BigNumber + ): BigNumber { + if (currentPoint >= endPoint) { + return endAmount; + } + + const elapsed = BigNumber.from(currentPoint - startPoint); + const duration = BigNumber.from(endPoint - startPoint); + let delta; + if (endAmount.lt(startAmount)) { + delta = BigNumber.from(0).sub((startAmount.sub(endAmount)).mul(elapsed).div(duration)); // mulDivDown in contract + } else { + delta = (endAmount.sub(startAmount)).mul(elapsed).div(duration); // mulDivDown in contract + } + return startAmount.add(delta); + } +} + +export { NonLinearDutchDecayLib }; + +export interface DutchBlockDecayConfig { + decayStartBlock: number; + startAmount: BigNumber; + relativeBlocks: number[]; + relativeAmounts: bigint[]; +} + +export function getBlockDecayedAmount( + config: DutchBlockDecayConfig, + atBlock: number +): BigNumber { + const { decayStartBlock, startAmount, relativeBlocks, relativeAmounts } = + config; + return NonLinearDutchDecayLib.decay( + { relativeAmounts, relativeBlocks }, + startAmount, + decayStartBlock, + atBlock + ); +} + +export function getEndAmount( + config: Partial +): BigNumber { + const { startAmount, relativeAmounts } = config; + if (!startAmount || !relativeAmounts) { + throw new Error("Invalid config for getting V3 decay end amount"); + } + return startAmount.sub( + relativeAmounts[relativeAmounts.length - 1].toString() + ); +} \ No newline at end of file diff --git a/sdks/uniswapx-sdk/src/utils/order.ts b/sdks/uniswapx-sdk/src/utils/order.ts index 7fe65328..67a2bf38 100644 --- a/sdks/uniswapx-sdk/src/utils/order.ts +++ b/sdks/uniswapx-sdk/src/utils/order.ts @@ -1,4 +1,4 @@ -import { ethers } from "ethers"; +import { BigNumber, ethers } from "ethers"; import { OrderType, REVERSE_REACTOR_MAPPING } from "../constants"; import { MissingConfiguration } from "../errors"; @@ -12,6 +12,7 @@ import { UnsignedPriorityOrder, UnsignedV2DutchOrder, } from "../order"; +import { CosignedV3DutchOrder, UnsignedV3DutchOrder } from "../order/V3DutchOrder"; import { stripHexPrefix } from "."; @@ -100,6 +101,16 @@ export class UniswapXOrderParser extends OrderParser { // if cosignature exists then returned cosigned version return cosignedOrder; } + case OrderType.Dutch_V3: { + // cosigned and unsigned serialized versions are the same format + const cosignedOrder = CosignedV3DutchOrder.parse(order, chainId); + // if no cosignature, returned unsigned variant + if (cosignedOrder.info.cosignature === "0x") { + return UnsignedV3DutchOrder.parse(order, chainId); + } + // if cosignature exists then returned cosigned version + return cosignedOrder; + } case OrderType.Priority: { // cosigned and unsigned serialized versions are the same format const cosignedOrder = CosignedPriorityOrder.parse(order, chainId); @@ -147,3 +158,7 @@ export class RelayOrderParser extends OrderParser { return RelayOrder.parse(order, chainId); } } + +export function originalIfZero(value: BigNumber, original: BigNumber): BigNumber { + return value.isZero() ? original : value; +} \ No newline at end of file