From d0ef575fd877fcc2f05bb79c89258790598c6744 Mon Sep 17 00:00:00 2001 From: Sam Jeston Date: Wed, 19 Jun 2024 13:25:17 +1000 Subject: [PATCH] fix: esm build compatibility (#1919) --- packages/orderbook/package.json | 3 +- .../src/api-client/api-client.test.ts | 2 +- packages/orderbook/src/seaport/components.ts | 4 +- packages/orderbook/src/seaport/lib/README.md | 1 + .../orderbook/src/seaport/lib/bulk-orders.ts | 61 ++++++++ .../orderbook/src/seaport/lib/defaults.ts | 112 +++++++++++++++ packages/orderbook/src/seaport/lib/merkle.ts | 133 ++++++++++++++++++ packages/orderbook/src/seaport/lib/utils.ts | 44 ++++++ .../orderbook/src/seaport/seaport.test.ts | 4 +- packages/orderbook/src/seaport/seaport.ts | 2 +- packages/orderbook/src/seaport/transaction.ts | 2 +- packages/orderbook/src/types.ts | 2 +- sdk/package.json | 1 + yarn.lock | 2 + 14 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 packages/orderbook/src/seaport/lib/README.md create mode 100644 packages/orderbook/src/seaport/lib/bulk-orders.ts create mode 100644 packages/orderbook/src/seaport/lib/defaults.ts create mode 100644 packages/orderbook/src/seaport/lib/merkle.ts create mode 100644 packages/orderbook/src/seaport/lib/utils.ts diff --git a/packages/orderbook/package.json b/packages/orderbook/package.json index d6e888a7c2..eb69b52c23 100644 --- a/packages/orderbook/package.json +++ b/packages/orderbook/package.json @@ -8,7 +8,8 @@ "@opensea/seaport-js": "4.0.2", "axios": "^1.6.5", "ethers": "^5.7.2", - "ethers-v6": "npm:ethers@6.11.1" + "ethers-v6": "npm:ethers@6.11.1", + "merkletreejs": "^0.3.11" }, "devDependencies": { "@rollup/plugin-typescript": "^11.0.0", diff --git a/packages/orderbook/src/api-client/api-client.test.ts b/packages/orderbook/src/api-client/api-client.test.ts index ff6d837d13..19144a026a 100644 --- a/packages/orderbook/src/api-client/api-client.test.ts +++ b/packages/orderbook/src/api-client/api-client.test.ts @@ -2,7 +2,7 @@ import { anything, deepEqual, instance, mock, when, } from 'ts-mockito'; import { ListingResult, OrdersService } from 'openapi/sdk'; -import { OrderComponents } from '@opensea/seaport-js/lib/types'; +import type { OrderComponents } from '@opensea/seaport-js/lib/types'; import { ItemType } from '../seaport'; import { ImmutableApiClient } from './api-client'; diff --git a/packages/orderbook/src/seaport/components.ts b/packages/orderbook/src/seaport/components.ts index 0fb8b5d16b..c6ea833c7d 100644 --- a/packages/orderbook/src/seaport/components.ts +++ b/packages/orderbook/src/seaport/components.ts @@ -1,6 +1,6 @@ -import { OrderComponents } from '@opensea/seaport-js/lib/types'; -import { getBulkOrderTree } from '@opensea/seaport-js/lib/utils/eip712/bulk-orders'; +import type { OrderComponents } from '@opensea/seaport-js/lib/types'; import { BigNumber } from 'ethers'; +import { getBulkOrderTree } from './lib/bulk-orders'; export function getOrderComponentsFromMessage(orderMessage: string): OrderComponents { const data = JSON.parse(orderMessage); diff --git a/packages/orderbook/src/seaport/lib/README.md b/packages/orderbook/src/seaport/lib/README.md new file mode 100644 index 0000000000..0f192c1a6f --- /dev/null +++ b/packages/orderbook/src/seaport/lib/README.md @@ -0,0 +1 @@ +The contents of this folder is copied directly from https://github.com/ProjectOpenSea/seaport-js/tree/main/src/utils/eip712 to work around an ESM module issue with the SDK. Once resolved this code can be removed and the import of get `getBulkOrderTree` can be reinstated. diff --git a/packages/orderbook/src/seaport/lib/bulk-orders.ts b/packages/orderbook/src/seaport/lib/bulk-orders.ts new file mode 100644 index 0000000000..2f951a0c69 --- /dev/null +++ b/packages/orderbook/src/seaport/lib/bulk-orders.ts @@ -0,0 +1,61 @@ +import type { OrderComponents } from '@opensea/seaport-js/lib/types'; +import { TypedDataEncoder, keccak256, toUtf8Bytes } from 'ethers-v6'; +import { EIP_712_ORDER_TYPE } from 'seaport/constants'; + +import { Eip712MerkleTree } from './merkle'; +import { DefaultGetter } from './defaults'; +import { fillArray } from './utils'; +import type { EIP712TypeDefinitions } from './defaults'; + +function getBulkOrderTypes(height: number): EIP712TypeDefinitions { + return { + ...EIP_712_ORDER_TYPE, + // eslint-disable-next-line @typescript-eslint/naming-convention + BulkOrder: [{ name: 'tree', type: `OrderComponents${'[2]'.repeat(height)}` }], + }; +} + +export function getBulkOrderTreeHeight(length: number): number { + return Math.max(Math.ceil(Math.log2(length)), 1); +} + +export function getBulkOrderTree( + orderComponents: OrderComponents[], + startIndex = 0, + height = getBulkOrderTreeHeight(orderComponents.length + startIndex), +) { + const types = getBulkOrderTypes(height); + const defaultNode = DefaultGetter.from(types, 'OrderComponents'); + let elements = [...orderComponents]; + + if (startIndex > 0) { + elements = [ + ...fillArray([] as OrderComponents[], startIndex, defaultNode), + ...orderComponents, + ]; + } + const tree = new Eip712MerkleTree( + types, + 'BulkOrder', + 'OrderComponents', + elements, + height, + ); + return tree; +} + +export function getBulkOrderTypeHash(height: number): string { + const types = getBulkOrderTypes(height); + const encoder = TypedDataEncoder.from(types); + const typeString = toUtf8Bytes(encoder.types.BulkOrder[0].type); + return keccak256(typeString); +} + +export function getBulkOrderTypeHashes(maxHeight: number): string[] { + const typeHashes: string[] = []; + // eslint-disable-next-line no-plusplus + for (let i = 0; i < maxHeight; i++) { + typeHashes.push(getBulkOrderTypeHash(i + 1)); + } + return typeHashes; +} diff --git a/packages/orderbook/src/seaport/lib/defaults.ts b/packages/orderbook/src/seaport/lib/defaults.ts new file mode 100644 index 0000000000..b53cc5014d --- /dev/null +++ b/packages/orderbook/src/seaport/lib/defaults.ts @@ -0,0 +1,112 @@ +import { ethers, zeroPadValue, TypedDataField } from 'ethers-v6'; + +const baseDefaults: Record = { + integer: 0, + address: ethers.zeroPadValue('0x', 20), + bool: false, + bytes: '0x', + string: '', +}; + +const isNullish = (value: any): boolean => { + if (value === undefined) return false; + + return ( + value !== undefined + && value !== null + && ((['string', 'number'].includes(typeof value) && BigInt(value) === 0n) + || (Array.isArray(value) && value.every(isNullish)) + || (typeof value === 'object' && Object.values(value).every(isNullish)) + || (typeof value === 'boolean' && value === false)) + ); +}; + +function getDefaultForBaseType(type: string): any { + // bytesXX + const [, width] = type.match(/^bytes(\d+)$/) ?? []; + // eslint-disable-next-line radix + if (width) return zeroPadValue('0x', parseInt(width)); + + // eslint-disable-next-line no-param-reassign + if (type.match(/^(u?)int(\d*)$/)) type = 'integer'; + + return baseDefaults[type]; +} + +export type EIP712TypeDefinitions = Record; + +type DefaultMap = { + [K in keyof T]: any; +}; + +export class DefaultGetter { + defaultValues: DefaultMap = {} as DefaultMap; + + constructor(protected types: Types) { + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const name in types) { + const defaultValue = this.getDefaultValue(name); + this.defaultValues[name] = defaultValue; + if (!isNullish(defaultValue)) { + throw new Error( + `Got non-empty value for type ${name} in default generator: ${defaultValue}`, + ); + } + } + } + + /* eslint-disable no-dupe-class-members */ + static from( + types: Types + ): DefaultMap; + + static from( + types: Types, + type: keyof Types + ): any; + + static from( + types: Types, + type?: keyof Types, + ): DefaultMap { + const { defaultValues } = new DefaultGetter(types); + if (type) return defaultValues[type]; + return defaultValues; + } + /* eslint-enable no-dupe-class-members */ + + getDefaultValue(type: string): any { + if (this.defaultValues[type]) return this.defaultValues[type]; + // Basic type (address, bool, uint256, etc) + const basic = getDefaultForBaseType(type); + if (basic !== undefined) return basic; + + // Array + const match = type.match(/^(.*)(\x5b(\d*)\x5d)$/); + if (match) { + const subtype = match[1]; + // eslint-disable-next-line radix + const length = parseInt(match[3]); + if (length > 0) { + const baseValue = this.getDefaultValue(subtype); + return Array(length).fill(baseValue); + } + return []; + } + + // Struct + const fields = this.types[type]; + if (fields) { + return fields.reduce( + // eslint-disable-next-line @typescript-eslint/no-shadow + (obj, { name, type }) => ({ + ...obj, + [name]: this.getDefaultValue(type), + }), + {}, + ); + } + + throw new Error(`unknown type: ${type}`); + } +} diff --git a/packages/orderbook/src/seaport/lib/merkle.ts b/packages/orderbook/src/seaport/lib/merkle.ts new file mode 100644 index 0000000000..456ee07569 --- /dev/null +++ b/packages/orderbook/src/seaport/lib/merkle.ts @@ -0,0 +1,133 @@ +import { + TypedDataEncoder, + AbiCoder, + keccak256, + toUtf8Bytes, + concat, +} from 'ethers-v6'; +import { MerkleTree } from 'merkletreejs'; + +import { DefaultGetter } from './defaults'; +import { + bufferKeccak, + bufferToHex, + chunk, + fillArray, + getRoot, + hexToBuffer, +} from './utils'; + +import type { EIP712TypeDefinitions } from './defaults'; + +type BulkOrderElements = + | [T, T] + | [BulkOrderElements, BulkOrderElements]; + +// eslint-disable-next-line max-len +const getTree = (leaves: string[], defaultLeafHash: string) => new MerkleTree(leaves.map(hexToBuffer), bufferKeccak, { + complete: true, + sort: false, + hashLeaves: false, + fillDefaultHash: hexToBuffer(defaultLeafHash), +}); + +const encodeProof = ( + key: number, + proof: string[], + signature = `0x${'ff'.repeat(64)}`, +) => concat([ + signature, + `0x${key.toString(16).padStart(6, '0')}`, + AbiCoder.defaultAbiCoder().encode([`uint256[${proof.length}]`], [proof]), +]); + +export class Eip712MerkleTree = any> { + tree: MerkleTree; + + private leafHasher: (value: any) => string; + + defaultNode: BaseType; + + defaultLeaf: string; + + encoder: TypedDataEncoder; + + get completedSize() { + return 2 ** this.depth; + } + + /** Returns the array of elements in the tree, padded to the complete size with empty items. */ + getCompleteElements() { + const { elements } = this; + return fillArray([...elements], this.completedSize, this.defaultNode); + } + + // eslint-disable-next-line max-len + /** Returns the array of leaf nodes in the tree, padded to the complete size with default hashes. */ + getCompleteLeaves() { + const leaves = this.elements.map(this.leafHasher); + return fillArray([...leaves], this.completedSize, this.defaultLeaf); + } + + get root() { + return this.tree.getHexRoot(); + } + + getProof(i: number) { + const leaves = this.getCompleteLeaves(); + const leaf = leaves[i]; + const proof = this.tree.getHexProof(leaf, i); + const root = this.tree.getHexRoot(); + return { leaf, proof, root }; + } + + getEncodedProofAndSignature(i: number, signature: string) { + const { proof } = this.getProof(i); + return encodeProof(i, proof, signature); + } + + getDataToSign(): BulkOrderElements { + let layer = this.getCompleteElements() as any; + while (layer.length > 2) { + layer = chunk(layer, 2); + } + return layer; + } + + add(element: BaseType) { + this.elements.push(element); + } + + getBulkOrderHash() { + const structHash = this.encoder.hashStruct('BulkOrder', { + tree: this.getDataToSign(), + }); + const leaves = this.getCompleteLeaves().map(hexToBuffer); + const rootHash = bufferToHex(getRoot(leaves, false)); + const typeHash = keccak256( + toUtf8Bytes(this.encoder.types.BulkOrder[0].type), + ); + const bulkOrderHash = keccak256(concat([typeHash, rootHash])); + + if (bulkOrderHash !== structHash) { + throw new Error('expected derived bulk order hash to match'); + } + + return structHash; + } + + constructor( + public types: EIP712TypeDefinitions, + public rootType: string, + public leafType: string, + public elements: BaseType[], + public depth: number, + ) { + const encoder = TypedDataEncoder.from(types); + this.encoder = encoder; + this.leafHasher = (leaf: BaseType) => encoder.hashStruct(leafType, leaf); + this.defaultNode = DefaultGetter.from(types, leafType); + this.defaultLeaf = this.leafHasher(this.defaultNode); + this.tree = getTree(this.getCompleteLeaves(), this.defaultLeaf); + } +} diff --git a/packages/orderbook/src/seaport/lib/utils.ts b/packages/orderbook/src/seaport/lib/utils.ts new file mode 100644 index 0000000000..bb14a98c09 --- /dev/null +++ b/packages/orderbook/src/seaport/lib/utils.ts @@ -0,0 +1,44 @@ +import { concat, toBeHex, keccak256 } from 'ethers-v6'; + +import type { BytesLike } from 'ethers-v6'; + +export const makeArray = (len: number, getValue: (i: number) => T) => Array(len) + .fill(0) + .map((_, i) => getValue(i)); + +// eslint-disable-next-line max-len +export const chunk = (array: T[], size: number) => makeArray(Math.ceil(array.length / size), (i) => array.slice(i * size, (i + 1) * size)); + +export const bufferToHex = (buf: Buffer) => toBeHex(buf.toString('hex')); + +export const hexToBuffer = (value: string) => Buffer.from(value.slice(2), 'hex'); + +export const bufferKeccak = (value: BytesLike) => hexToBuffer(keccak256(value)); + +export const hashConcat = (arr: BytesLike[]) => bufferKeccak(concat(arr)); + +export const fillArray = (arr: T[], length: number, value: T) => { + if (length > arr.length) arr.push(...Array(length - arr.length).fill(value)); + return arr; +}; + +export const getRoot = (elements: (Buffer | string)[], hashLeaves = true) => { + if (elements.length === 0) throw new Error('empty tree'); + + const leaves = elements.map((e) => { + const leaf = Buffer.isBuffer(e) ? e : hexToBuffer(e); + return hashLeaves ? bufferKeccak(leaf) : leaf; + }); + + const layers: Buffer[][] = [leaves]; + + // Get next layer until we reach the root + while (layers[layers.length - 1].length > 1) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + layers.push(getNextLayer(layers[layers.length - 1])); + } + + return layers[layers.length - 1][0]; +}; + +export const getNextLayer = (elements: Buffer[]) => chunk(elements, 2).map(hashConcat); diff --git a/packages/orderbook/src/seaport/seaport.test.ts b/packages/orderbook/src/seaport/seaport.test.ts index 1e38bdcf4c..2e23996738 100644 --- a/packages/orderbook/src/seaport/seaport.test.ts +++ b/packages/orderbook/src/seaport/seaport.test.ts @@ -1,10 +1,10 @@ import { anything, deepEqual, instance, mock, when, } from 'ts-mockito'; -import { TransactionMethods } from '@opensea/seaport-js/lib/utils/usecase'; +import type { TransactionMethods } from '@opensea/seaport-js/lib/utils/usecase'; import { ContractTransaction } from 'ethers-v6'; import { Seaport as SeaportLib } from '@opensea/seaport-js'; -import { +import type { ApprovalAction, CreateOrderAction, ExchangeAction, diff --git a/packages/orderbook/src/seaport/seaport.ts b/packages/orderbook/src/seaport/seaport.ts index a52477caa2..5bb403ca5e 100644 --- a/packages/orderbook/src/seaport/seaport.ts +++ b/packages/orderbook/src/seaport/seaport.ts @@ -1,5 +1,5 @@ import { Seaport as SeaportLib } from '@opensea/seaport-js'; -import { +import type { ApprovalAction, CreateInputItem, CreateOrderAction, diff --git a/packages/orderbook/src/seaport/transaction.ts b/packages/orderbook/src/seaport/transaction.ts index 4cf536bfbe..ea1e3409c9 100644 --- a/packages/orderbook/src/seaport/transaction.ts +++ b/packages/orderbook/src/seaport/transaction.ts @@ -1,4 +1,4 @@ -import { TransactionMethods } from '@opensea/seaport-js/lib/utils/usecase'; +import type { TransactionMethods } from '@opensea/seaport-js/lib/utils/usecase'; import { PopulatedTransaction, BigNumber } from 'ethers'; import { TransactionBuilder } from 'types'; diff --git a/packages/orderbook/src/types.ts b/packages/orderbook/src/types.ts index cb49a845e9..7e103390fa 100644 --- a/packages/orderbook/src/types.ts +++ b/packages/orderbook/src/types.ts @@ -1,4 +1,4 @@ -import { OrderComponents } from '@opensea/seaport-js/lib/types'; +import type { OrderComponents } from '@opensea/seaport-js/lib/types'; import { PopulatedTransaction, TypedDataDomain, TypedDataField } from 'ethers'; import { Fee as OpenapiFee, OrdersService, OrderStatus } from './openapi/sdk'; diff --git a/sdk/package.json b/sdk/package.json index eb9a770c5b..d08c73c7aa 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -45,6 +45,7 @@ "jwt-decode": "^3.1.2", "lru-memorise": "0.3.0", "magic-sdk": "^21.2.0", + "merkletreejs": "^0.3.11", "oidc-client-ts": "2.4.0", "os-browserify": "^0.3.0", "pako": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 702b6185c1..54e4be579b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3755,6 +3755,7 @@ __metadata: ethers-v6: "npm:ethers@6.11.1" jest: ^29.4.3 jest-environment-jsdom: ^29.4.3 + merkletreejs: ^0.3.11 rollup: ^3.17.2 ts-mockito: ^2.6.1 typechain: ^8.1.1 @@ -3921,6 +3922,7 @@ __metadata: jwt-decode: ^3.1.2 lru-memorise: 0.3.0 magic-sdk: ^21.2.0 + merkletreejs: ^0.3.11 oidc-client-ts: 2.4.0 os-browserify: ^0.3.0 pako: ^2.1.0