diff --git a/.gitignore b/.gitignore index fe32fcce..bb47aaa3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ deploy.sh # turbo .turbo build/** -dist/** \ No newline at end of file +dist/** + +# Local subgraph E2E diagnostics +subgraphs/launchpad/.e2e-artifacts/ diff --git a/config/robinhood.js b/config/robinhood.js new file mode 100644 index 00000000..0d69e167 --- /dev/null +++ b/config/robinhood.js @@ -0,0 +1,58 @@ +const NATIVE_ADDRESS = "0x0bd7d308f8e1639fab988df18a8011f41eacad73"; +const USDG_ADDRESS = "0x5fc5360d0400a0fd4f2af552add042d716f1d168"; + +module.exports = { + network: "robinhood-mainnet", + blocks: { + address: "0xe52abd50ad151ecdf56427effd715e703696a6b1", + startBlock: 0, + }, + v2: { + nativeAddress: NATIVE_ADDRESS, + whitelistAddresses: [ + // IMPORTANT! Native should be included here + NATIVE_ADDRESS, + USDG_ADDRESS, + ], + stable0: USDG_ADDRESS, + stable1: "0x0000000000000000000000000000000000000000", + stable2: "0x0000000000000000000000000000000000000000", + minimumNativeLiquidity: 0.5, + factory: { + address: "0xe52abd50ad151ecdf56427effd715e703696a6b1", + initCodeHash: + "0xe18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303", + startBlock: 6269958, + }, + }, + v3: { + factory: { + address: "0xe51960f1b45f1c9fb6d166e6a884f866fc70433b", + startBlock: 6292626, + }, + positionManager: { + address: "0x51d0e5188afe12d502e29d982d20c190e7816107", + startBlock: 6305802, + }, + native: { address: NATIVE_ADDRESS }, + whitelistedTokenAddresses: [ + // IMPORTANT! Native should be included here + NATIVE_ADDRESS, + USDG_ADDRESS, + ], + stableTokenAddresses: [USDG_ADDRESS], + nativePricePool: "0xc5a01c57b2851202dcf8507a0f2bd08a8025e2c8", // WETH/USDG - 0.3% (deterministic address) + minimumEthLocked: 0.5, + }, + launchpad: { + deployments: [ + { + // SushiLaunchpad v1 production deployment. + name: "SushiLaunchpad", + chainId: 4663, + address: "0x30DD6230EAD9312D5d00AD58EF6eF6A0093B0554", + startBlock: 18149814, + }, + ], + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8b1d301..d550eb24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -51,7 +51,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -69,7 +69,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -86,11 +86,41 @@ importers: specifier: 1.0.24 version: 1.0.24 + subgraphs/launchpad: + devDependencies: + '@graphprotocol/graph-cli': + specifier: ^0.96.0 + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) + '@graphprotocol/graph-ts': + specifier: ^0.27.0 + version: 0.27.0 + '@types/node': + specifier: ^22.13.11 + version: 22.13.11 + assemblyscript: + specifier: ^0.19.20 + version: 0.19.23 + ethers: + specifier: ^6.14.0 + version: 6.17.0 + matchstick-as: + specifier: 0.6.0 + version: 0.6.0 + tsx: + specifier: ^4.20.3 + version: 4.23.1 + typescript: + specifier: ^5.8.2 + version: 5.8.2 + wabt: + specifier: 1.0.24 + version: 1.0.24 + subgraphs/route-processor: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -111,7 +141,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -129,7 +159,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -150,7 +180,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -171,7 +201,7 @@ importers: devDependencies: '@graphprotocol/graph-cli': specifier: ^0.96.0 - version: 0.96.0(typescript@5.8.2) + version: 0.96.0(@types/node@22.13.11)(typescript@5.8.2) '@graphprotocol/graph-ts': specifier: ^0.27.0 version: 0.27.0 @@ -190,6 +220,10 @@ importers: packages: + /@adraffy/ens-normalize@1.11.1: + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + dev: true + /@babel/code-frame@7.26.2: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -214,6 +248,240 @@ packages: '@chainsafe/is-ip': 2.1.0 dev: true + /@esbuild/aix-ppc64@0.28.1: + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.28.1: + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.28.1: + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.28.1: + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.28.1: + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.28.1: + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.28.1: + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.28.1: + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.28.1: + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.28.1: + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.28.1: + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.28.1: + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.28.1: + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.28.1: + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.28.1: + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.28.1: + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.28.1: + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-arm64@0.28.1: + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.28.1: + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-arm64@0.28.1: + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.28.1: + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openharmony-arm64@0.28.1: + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.28.1: + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.28.1: + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.28.1: + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.28.1: + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@float-capital/float-subgraph-uncrashable@0.0.0-internal-testing.5: resolution: {integrity: sha512-yZ0H5e3EpAYKokX/AbtplzlvSxEJY7ZfpvQyDzyODkks0hakAAlDG6fQu1SlDJMWorY7bbq1j7fCiFeTWci6TA==} hasBin: true @@ -224,7 +492,7 @@ packages: js-yaml: 4.1.0 dev: true - /@graphprotocol/graph-cli@0.96.0(typescript@5.8.2): + /@graphprotocol/graph-cli@0.96.0(@types/node@22.13.11)(typescript@5.8.2): resolution: {integrity: sha512-zjVMtzoipxj5tBEAXFBVAIHT6kkxMYauXHX00/IoTu3pE26hdt4ogV1vbfBai/TnPkQCDR86vJiN0XqPfc+7sw==} engines: {node: '>=20.18.1'} hasBin: true @@ -232,7 +500,7 @@ packages: '@float-capital/float-subgraph-uncrashable': 0.0.0-internal-testing.5 '@oclif/core': 4.2.6 '@oclif/plugin-autocomplete': 3.2.26 - '@oclif/plugin-not-found': 3.2.47 + '@oclif/plugin-not-found': 3.2.47(@types/node@22.13.11) '@oclif/plugin-warn-if-update-available': 3.1.38 '@pinax/graph-networks-registry': 0.6.7 '@whatwg-node/fetch': 0.10.5 @@ -270,7 +538,7 @@ packages: assemblyscript: 0.19.10 dev: true - /@inquirer/checkbox@4.1.4: + /@inquirer/checkbox@4.1.4(@types/node@22.13.11): resolution: {integrity: sha512-d30576EZdApjAMceijXA5jDzRQHT/MygbC+J8I7EqA6f/FRpYxlRtRJbHF8gHeWYeSdOuTEJqonn7QLB1ELezA==} engines: {node: '>=18'} peerDependencies: @@ -279,14 +547,15 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 + '@inquirer/core': 10.1.9(@types/node@22.13.11) '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.5 + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 dev: true - /@inquirer/confirm@5.1.8: + /@inquirer/confirm@5.1.8(@types/node@22.13.11): resolution: {integrity: sha512-dNLWCYZvXDjO3rnQfk2iuJNL4Ivwz/T2+C3+WnNfJKsNGSuOs3wAo2F6e0p946gtSAk31nZMfW+MRmYaplPKsg==} engines: {node: '>=18'} peerDependencies: @@ -295,11 +564,12 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 dev: true - /@inquirer/core@10.1.9: + /@inquirer/core@10.1.9(@types/node@22.13.11): resolution: {integrity: sha512-sXhVB8n20NYkUBfDYgizGHlpRVaCRjtuzNZA6xpALIUbkgfd2Hjz+DfEN6+h1BRnuxw0/P4jCIMjMsEOAMwAJw==} engines: {node: '>=18'} peerDependencies: @@ -309,7 +579,8 @@ packages: optional: true dependencies: '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.5 + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -318,7 +589,7 @@ packages: yoctocolors-cjs: 2.1.2 dev: true - /@inquirer/editor@4.2.9: + /@inquirer/editor@4.2.9(@types/node@22.13.11): resolution: {integrity: sha512-8HjOppAxO7O4wV1ETUlJFg6NDjp/W2NP5FB9ZPAcinAlNT4ZIWOLe2pUVwmmPRSV0NMdI5r/+lflN55AwZOKSw==} engines: {node: '>=18'} peerDependencies: @@ -327,12 +598,13 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 external-editor: 3.1.0 dev: true - /@inquirer/expand@4.0.11: + /@inquirer/expand@4.0.11(@types/node@22.13.11): resolution: {integrity: sha512-OZSUW4hFMW2TYvX/Sv+NnOZgO8CHT2TU1roUCUIF2T+wfw60XFRRp9MRUPCT06cRnKL+aemt2YmTWwt7rOrNEA==} engines: {node: '>=18'} peerDependencies: @@ -341,8 +613,9 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 yoctocolors-cjs: 2.1.2 dev: true @@ -351,7 +624,7 @@ packages: engines: {node: '>=18'} dev: true - /@inquirer/input@4.1.8: + /@inquirer/input@4.1.8(@types/node@22.13.11): resolution: {integrity: sha512-WXJI16oOZ3/LiENCAxe8joniNp8MQxF6Wi5V+EBbVA0ZIOpFcL4I9e7f7cXse0HJeIPCWO8Lcgnk98juItCi7Q==} engines: {node: '>=18'} peerDependencies: @@ -360,11 +633,12 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 dev: true - /@inquirer/number@3.0.11: + /@inquirer/number@3.0.11(@types/node@22.13.11): resolution: {integrity: sha512-pQK68CsKOgwvU2eA53AG/4npRTH2pvs/pZ2bFvzpBhrznh8Mcwt19c+nMO7LHRr3Vreu1KPhNBF3vQAKrjIulw==} engines: {node: '>=18'} peerDependencies: @@ -373,11 +647,12 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 dev: true - /@inquirer/password@4.0.11: + /@inquirer/password@4.0.11(@types/node@22.13.11): resolution: {integrity: sha512-dH6zLdv+HEv1nBs96Case6eppkRggMe8LoOTl30+Gq5Wf27AO/vHFgStTVz4aoevLdNXqwE23++IXGw4eiOXTg==} engines: {node: '>=18'} peerDependencies: @@ -386,12 +661,13 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 ansi-escapes: 4.3.2 dev: true - /@inquirer/prompts@7.4.0: + /@inquirer/prompts@7.4.0(@types/node@22.13.11): resolution: {integrity: sha512-EZiJidQOT4O5PYtqnu1JbF0clv36oW2CviR66c7ma4LsupmmQlUwmdReGKRp456OWPWMz3PdrPiYg3aCk3op2w==} engines: {node: '>=18'} peerDependencies: @@ -400,19 +676,20 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/checkbox': 4.1.4 - '@inquirer/confirm': 5.1.8 - '@inquirer/editor': 4.2.9 - '@inquirer/expand': 4.0.11 - '@inquirer/input': 4.1.8 - '@inquirer/number': 3.0.11 - '@inquirer/password': 4.0.11 - '@inquirer/rawlist': 4.0.11 - '@inquirer/search': 3.0.11 - '@inquirer/select': 4.1.0 + '@inquirer/checkbox': 4.1.4(@types/node@22.13.11) + '@inquirer/confirm': 5.1.8(@types/node@22.13.11) + '@inquirer/editor': 4.2.9(@types/node@22.13.11) + '@inquirer/expand': 4.0.11(@types/node@22.13.11) + '@inquirer/input': 4.1.8(@types/node@22.13.11) + '@inquirer/number': 3.0.11(@types/node@22.13.11) + '@inquirer/password': 4.0.11(@types/node@22.13.11) + '@inquirer/rawlist': 4.0.11(@types/node@22.13.11) + '@inquirer/search': 3.0.11(@types/node@22.13.11) + '@inquirer/select': 4.1.0(@types/node@22.13.11) + '@types/node': 22.13.11 dev: true - /@inquirer/rawlist@4.0.11: + /@inquirer/rawlist@4.0.11(@types/node@22.13.11): resolution: {integrity: sha512-uAYtTx0IF/PqUAvsRrF3xvnxJV516wmR6YVONOmCWJbbt87HcDHLfL9wmBQFbNJRv5kCjdYKrZcavDkH3sVJPg==} engines: {node: '>=18'} peerDependencies: @@ -421,12 +698,13 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 - '@inquirer/type': 3.0.5 + '@inquirer/core': 10.1.9(@types/node@22.13.11) + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 yoctocolors-cjs: 2.1.2 dev: true - /@inquirer/search@3.0.11: + /@inquirer/search@3.0.11(@types/node@22.13.11): resolution: {integrity: sha512-9CWQT0ikYcg6Ls3TOa7jljsD7PgjcsYEM0bYE+Gkz+uoW9u8eaJCRHJKkucpRE5+xKtaaDbrND+nPDoxzjYyew==} engines: {node: '>=18'} peerDependencies: @@ -435,13 +713,14 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 + '@inquirer/core': 10.1.9(@types/node@22.13.11) '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.5 + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 yoctocolors-cjs: 2.1.2 dev: true - /@inquirer/select@4.1.0: + /@inquirer/select@4.1.0(@types/node@22.13.11): resolution: {integrity: sha512-z0a2fmgTSRN+YBuiK1ROfJ2Nvrpij5lVN3gPDkQGhavdvIVGHGW29LwYZfM/j42Ai2hUghTI/uoBuTbrJk42bA==} engines: {node: '>=18'} peerDependencies: @@ -450,14 +729,15 @@ packages: '@types/node': optional: true dependencies: - '@inquirer/core': 10.1.9 + '@inquirer/core': 10.1.9(@types/node@22.13.11) '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.5 + '@inquirer/type': 3.0.5(@types/node@22.13.11) + '@types/node': 22.13.11 ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 dev: true - /@inquirer/type@3.0.5: + /@inquirer/type@3.0.5(@types/node@22.13.11): resolution: {integrity: sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==} engines: {node: '>=18'} peerDependencies: @@ -465,6 +745,8 @@ packages: peerDependenciesMeta: '@types/node': optional: true + dependencies: + '@types/node': 22.13.11 dev: true /@ipld/dag-cbor@9.2.2: @@ -577,6 +859,12 @@ packages: uint8arrays: 5.1.0 dev: true + /@noble/curves@1.2.0: + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + dependencies: + '@noble/hashes': 1.3.2 + dev: true + /@noble/curves@1.4.2: resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} dependencies: @@ -590,6 +878,11 @@ packages: '@noble/hashes': 1.7.1 dev: true + /@noble/hashes@1.3.2: + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + dev: true + /@noble/hashes@1.4.0: resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -657,11 +950,11 @@ packages: - supports-color dev: true - /@oclif/plugin-not-found@3.2.47: + /@oclif/plugin-not-found@3.2.47(@types/node@22.13.11): resolution: {integrity: sha512-7Zk10TQhPOd5kkS4wiLDBqtR2MRM8FBiSrZDmSsgME1kXt4eYQLAFc9c5WJzkpglNb6g5/iD1LvbhTNd5oLe1g==} engines: {node: '>=18.0.0'} dependencies: - '@inquirer/prompts': 7.4.0 + '@inquirer/prompts': 7.4.0(@types/node@22.13.11) '@oclif/core': 4.2.6 ansis: 3.17.0 fast-levenshtein: 3.0.0 @@ -734,7 +1027,7 @@ packages: /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: - '@types/node': 12.20.55 + '@types/node': 22.13.11 dev: true /@types/dns-packet@5.6.5: @@ -753,6 +1046,12 @@ packages: undici-types: 6.20.0 dev: true + /@types/node@22.7.5: + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + dependencies: + undici-types: 6.19.8 + dev: true + /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: true @@ -760,7 +1059,7 @@ packages: /@types/ws@7.4.7: resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} dependencies: - '@types/node': 12.20.55 + '@types/node': 22.13.11 dev: true /@whatwg-node/disposablestack@0.0.6: @@ -816,6 +1115,10 @@ packages: typescript: 5.8.2 dev: true + /aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + dev: true + /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1358,6 +1661,40 @@ packages: es6-promise: 4.2.8 dev: true + /esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + dev: true + /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -1377,6 +1714,22 @@ packages: '@scure/bip39': 1.3.0 dev: true + /ethers@6.17.0: + resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} + engines: {node: '>=14.0.0'} + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} dev: true @@ -1502,6 +1855,14 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} dev: true @@ -2762,10 +3123,24 @@ packages: is-number: 7.0.0 dev: true + /tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + dev: true + /tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: true + /tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} dependencies: @@ -2862,6 +3237,10 @@ packages: multiformats: 13.3.2 dev: true + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + dev: true + /undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} dev: true @@ -3048,6 +3427,19 @@ packages: optional: true dev: true + /ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true diff --git a/subgraphs/launchpad/README.md b/subgraphs/launchpad/README.md new file mode 100644 index 00000000..f2dd3faa --- /dev/null +++ b/subgraphs/launchpad/README.md @@ -0,0 +1,160 @@ +# Sushi Launchpad Subgraph + +Canonical, event-driven projection of Sushi Launchpad factories. Market swaps, +prices, candles, TVL, and volume stay in the existing Sushi V3 subgraph and the +data-api market projection. + +## Entities + +- `Launchpad` stores one factory deployment, its current protocol recipient, + native launch fee, default Sushi fee, protocol reserve settings, and + aggregate token, creator, and position counts. +- `Creator` provides the per-Launchpad identity needed to count distinct + creators and links each creator to their tokens. +- `Token` stores ERC-20 metadata, its creator, per-launch quote token, current + Sushi fee, reserve state, cumulative fee amounts, and its pool. +- `Pool` stores the immutable V3 configuration (`token0`, `token1`, fee, tick + spacing, and position manager) and links to its initial launch positions. +- `LaunchPosition` stores only the V3 NFTs minted in the token launch + transaction. Desired and used amounts are normalized to `amount0` and + `amount1` according to the pool's canonical token ordering. +- `FeeDistribution` stores each collection and split with both recipients and + all token0/token1 amounts. `ReserveWithdrawal` records the token's one + protocol-reserve withdrawal. +- `LaunchFeeWithdrawal` stores each withdrawal of accumulated native launch + fees with its amount and recipient. + +`Pool.positionCount` also reconciles the ordered `PositionCreated` events with +the final `TokenLaunched.positionCount`. No temporary accumulator entity is +needed: the pool and positions are permanent domain records even though the +contract emits `TokenLaunched` last. + +Every stable entity ID is chain-scoped even though each deployment indexes one +network. Address and hash values remain available as `Bytes` for Graph-native +consumers. The five entities mirrored into data-api also expose parallel +`*Hex` string fields containing canonical lowercase, `0x`-prefixed hexadecimal. +Those strings avoid connector-specific binary encodings such as PostgreSQL +bytea's `\x` prefix and are the source of truth for text columns downstream. + +## Configuration + +Each `config/.js` file may register multiple current or historical +factory deployments: + +```js +launchpad: { + deployments: [ + { + name: "SushiLaunchpadV1", + chainId: 4663, + address: "0x...", + startBlock: 123, + }, + ], +} +``` + +All deployments on a network share the schema and mappings. A quote token is +selected independently for each launch and persisted on its `Token` entity. +The subgraph indexes every valid quote token emitted by the contract; public +allowlisting and market filtering belong downstream in data-api. The mapping +reads the position manager, protocol recipient, launch fee, and initial +defaults from the Launchpad contract, then reads canonical token ordering, fee, +and tick spacing from each created V3 pool. Add old and new factory versions to +the array during a rotation; do not replace historical entries. Keep the first +historical data source named `SushiLaunchpad`; the shared mapping imports its +generated ABI bindings, while later entries use unique names such as +`SushiLaunchpadV2`. + +The mapping is split by responsibility: `helpers.ts` owns chain-scoped IDs and +entity loading, `pool.ts` owns pool discovery and initial launch positions, +`token.ts` owns token-level flows, and `launchpad.ts` owns factory-wide settings +and provides the manifest exports. + +Robinhood is configured for the SushiLaunchpad v1 production deployment at +`0x30DD6230EAD9312D5d00AD58EF6eF6A0093B0554`, starting at block `18149814`. + +## Goldsky deployment + +Generate and validate the Robinhood manifest, then deploy it to the existing +`sushiswap` Goldsky project: + +```sh +NETWORK=robinhood pnpm --filter launchpad generate +pnpm --filter launchpad build +goldsky subgraph deploy sushiswap/launchpad-robinhood-v2 \ + --path subgraphs/launchpad/build \ + --description "Sushi Launchpad on Robinhood with canonical hex strings" +``` + +The generated `subgraph.yaml` and `build/` output are ignored build artifacts; +regenerate them after checking out a fresh tree. + +## Development + +```sh +NETWORK=robinhood pnpm generate +pnpm build +pnpm test +``` + +`abis/SushiLaunchpad.json` is ABI-equivalent to the current `SushiLaunchpad` +artifact at contract commit `050bfbd`; the indexed interface is unchanged from +the previous `ee5f8bb` revision. Refresh it together with mappings and tests +when an indexed event changes. + +## Local end-to-end test + +The E2E harness runs the real Launchpad and launched token contracts against a +Hardhat chain, indexes their events with a local Graph Node, and compares every +entity with an independently constructed expected projection. The Sushi V3 +factory, pools, position manager, and per-launch quote tokens use the contract +repository's behavioral mocks so fee collection and canonical token ordering +remain fast and deterministic. + +Prerequisites: + +- Node.js 24 and pnpm/Corepack; +- Docker with either the Compose plugin or the `docker-compose` command; +- a sibling `sushi-launchpad` checkout with dependencies installed. From this + package, the default location resolves to `../../../sushi-launchpad`. + +Run the default deterministic matrix: + +```sh +pnpm test:e2e +``` + +The deterministic planner and Node.js harness can be checked without starting +Docker or Hardhat: + +```sh +pnpm test:e2e:planner +pnpm test:e2e:typecheck +``` + +Reproduce one scenario or point to a different contract checkout: + +```sh +pnpm test:e2e -- --seed 202 +LAUNCHPAD_CONTRACTS_DIR=/path/to/sushi-launchpad pnpm test:e2e -- --seed 202 +``` + +The default seeds are `101`, `202`, and `303`. Each scenario is fully prepared +before transactions are sent and varies actors, launch ranges, fees, canonical +token ordering, and valid action ordering while guaranteeing coverage of every +indexed lifecycle event. The first launch and factory-default changes are mined +in one block to exercise Graph Node's block-visible contract-call semantics. + +The runner requires ports `8545`, `8000`, `8001`, `8020`, `8030`, `8040`, +`5001`, and `5432`. It fails before startup if any are occupied. On success or +failure it stops Hardhat and removes the E2E containers and volumes. Plans, +transaction traces, GraphQL snapshots, expected projections, and service logs +are retained in the ignored `.e2e-artifacts/` directory; the failing seed and +artifact path are printed for reproduction. + +The harness uses its own minimal Hardhat node configuration targeting Cancun +with a 60M block and transaction gas cap. Cancun matches the contracts' compile +target while avoiding newer per-transaction protocol caps below Graph Node's +50M historical contract-call allowance. The contract repository's development +and production config remains unchanged. diff --git a/subgraphs/launchpad/abis/SushiLaunchpad.json b/subgraphs/launchpad/abis/SushiLaunchpad.json new file mode 100644 index 00000000..c63e3c18 --- /dev/null +++ b/subgraphs/launchpad/abis/SushiLaunchpad.json @@ -0,0 +1,1354 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + }, + { + "internalType": "address", + "name": "initialProtocolRecipient", + "type": "address" + }, + { + "internalType": "address", + "name": "factory_", + "type": "address" + }, + { + "internalType": "address", + "name": "positionManager_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "deadline", + "type": "uint64" + } + ], + "name": "DeadlineExpired", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyName", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyRanges", + "type": "error" + }, + { + "inputs": [], + "name": "EmptySymbol", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "used", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "desired", + "type": "uint256" + } + ], + "name": "ExcessTokenConsumption", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "required", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "supplied", + "type": "uint256" + } + ], + "name": "InsufficientLaunchFee", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "bps", + "type": "uint16" + } + ], + "name": "InvalidFeeBps", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPositionManager", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "quoteToken", + "type": "address" + } + ], + "name": "InvalidQuoteToken", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "int24", + "name": "startTick", + "type": "int24" + }, + { + "internalType": "int24", + "name": "endTick", + "type": "int24" + } + ], + "name": "InvalidRangeOrder", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "bps", + "type": "uint16" + } + ], + "name": "InvalidReserveBps", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "int24", + "name": "tick", + "type": "int24" + } + ], + "name": "InvalidTickBounds", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "int24", + "name": "actualTickSpacing", + "type": "int24" + } + ], + "name": "InvalidTickSpacing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "int24", + "name": "tick", + "type": "int24" + } + ], + "name": "InvalidTickSpacingAt", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "length", + "type": "uint256" + } + ], + "name": "NameTooLong", + "type": "error" + }, + { + "inputs": [], + "name": "NativeTransferFailed", + "type": "error" + }, + { + "inputs": [], + "name": "NoCleanTokenAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "int24", + "name": "previousEndTick", + "type": "int24" + }, + { + "internalType": "int24", + "name": "startTick", + "type": "int24" + } + ], + "name": "NonContiguousRanges", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "NonzeroQuoteTokenUsed", + "type": "error" + }, + { + "inputs": [], + "name": "NothingToWithdraw", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "OwnershipRenunciationDisabled", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pool", + "type": "address" + } + ], + "name": "PoolConfigurationMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "expected", + "type": "address" + }, + { + "internalType": "address", + "name": "actual", + "type": "address" + } + ], + "name": "PoolCreationMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint160", + "name": "expected", + "type": "uint160" + }, + { + "internalType": "uint160", + "name": "actual", + "type": "uint160" + } + ], + "name": "PoolInitializationMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "positionId", + "type": "uint256" + } + ], + "name": "PositionConfigurationMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "RangeAmountOverflow", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "supplied", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "allocation", + "type": "uint256" + } + ], + "name": "RangeAmountsExceedAllocation", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "ReserveAlreadyWithdrawn", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "unlockAt", + "type": "uint64" + } + ], + "name": "ReserveStillLocked", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expected", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "accounted", + "type": "uint256" + } + ], + "name": "SupplyReconciliationFailure", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "length", + "type": "uint256" + } + ], + "name": "SymbolTooLong", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "supplied", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "maximum", + "type": "uint256" + } + ], + "name": "TooManyRanges", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "address", + "name": "pool", + "type": "address" + } + ], + "name": "UnexpectedExistingPool", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "collection", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "address", + "name": "from", + "type": "address" + } + ], + "name": "UnexpectedNft", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "UnknownToken", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "ZeroLiquidity", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "ZeroRangeAmount", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "previousBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "newBps", + "type": "uint16" + } + ], + "name": "DefaultSushiFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "creator", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "protocolRecipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "sushiFeeBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "quoteCollected", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenCollected", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "quoteToSushi", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenToSushi", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "quoteToCreator", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenToCreator", + "type": "uint256" + } + ], + "name": "FeesDistributed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "previousFee", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "LaunchFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "LaunchFeesWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "positionId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "indexed": false, + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenDesired", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenUsed", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "liquidity", + "type": "uint128" + } + ], + "name": "PositionCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousRecipient", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newRecipient", + "type": "address" + } + ], + "name": "ProtocolRecipientUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "previousBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "newBps", + "type": "uint16" + } + ], + "name": "ProtocolReserveBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ProtocolReserveWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "previousSushiFeeBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "newSushiFeeBps", + "type": "uint16" + } + ], + "name": "SushiFeeBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "creator", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "quoteToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "symbol", + "type": "string" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "decimals", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalSupply", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "reserveBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "reserveAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "reserveUnlockAt", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "initialSushiFeeBps", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "positionCount", + "type": "uint256" + } + ], + "name": "TokenLaunched", + "type": "event" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "defaultSushiFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "distributeFees", + "outputs": [ + { + "internalType": "uint256", + "name": "quoteCollected", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "tokenCollected", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "quoteToSushi", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "tokenToSushi", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + } + ], + "internalType": "struct ISushiLaunchpad.TokenConfig", + "name": "tokenConfig", + "type": "tuple" + }, + { + "internalType": "address", + "name": "quoteToken", + "type": "address" + }, + { + "components": [ + { + "internalType": "int24", + "name": "startTick", + "type": "int24" + }, + { + "internalType": "int24", + "name": "endTick", + "type": "int24" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "internalType": "struct ISushiLaunchpad.SaleRange[]", + "name": "ranges", + "type": "tuple[]" + }, + { + "internalType": "uint64", + "name": "deadline", + "type": "uint64" + } + ], + "name": "launch", + "outputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint256[]", + "name": "positionIds", + "type": "uint256[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "launchFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "launchInfo", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "creator", + "type": "address" + }, + { + "internalType": "address", + "name": "quoteToken", + "type": "address" + }, + { + "internalType": "address", + "name": "pool", + "type": "address" + }, + { + "internalType": "uint64", + "name": "reserveUnlockAt", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "reserveWithdrawn", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "reserveAmount", + "type": "uint256" + } + ], + "internalType": "struct ISushiLaunchpad.LaunchInfo", + "name": "info", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "onERC721Received", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pendingOwner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "positionManager", + "outputs": [ + { + "internalType": "contract INonfungiblePositionManager", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "protocolRecipient", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "protocolReserveBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "newSushiFeeBps", + "type": "uint16" + } + ], + "name": "setDefaultSushiFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newLaunchFee", + "type": "uint256" + } + ], + "name": "setLaunchFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newRecipient", + "type": "address" + } + ], + "name": "setProtocolRecipient", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "newReserveBps", + "type": "uint16" + } + ], + "name": "setProtocolReserveBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint16", + "name": "newSushiFeeBps", + "type": "uint16" + } + ], + "name": "setSushiFeeBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "sushiFeeBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "v3Factory", + "outputs": [ + { + "internalType": "contract ISushiV3Factory", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawLaunchFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "withdrawProtocolReserve", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/subgraphs/launchpad/e2e/docker-compose.yml b/subgraphs/launchpad/e2e/docker-compose.yml new file mode 100644 index 00000000..253bf0c5 --- /dev/null +++ b/subgraphs/launchpad/e2e/docker-compose.yml @@ -0,0 +1,61 @@ +services: + graph-node: + image: graphprotocol/graph-node:v0.41.2@sha256:5dd0622b0bcb52ce581911cf25ade5dc3c7c3d66638d2acab713cefa6d7f76f8 + ports: + - "127.0.0.1:8000:8000" + - "127.0.0.1:8001:8001" + - "127.0.0.1:8020:8020" + - "127.0.0.1:8030:8030" + - "127.0.0.1:8040:8040" + depends_on: + ipfs: + condition: service_healthy + postgres: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + postgres_host: postgres + postgres_user: graph-node + postgres_pass: launchpad-e2e + postgres_db: graph-node + ipfs: "ipfs:5001" + ethereum: "hardhat:http://host.docker.internal:8545" + GRAPH_LOG: info + + ipfs: + image: ipfs/kubo:v0.17.0@sha256:803fac58ba15bd763b97a1ce17bca57f75348f2088d384a908b77e8540f35560 + ports: + - "127.0.0.1:5001:5001" + healthcheck: + test: ["CMD", "ipfs", "id"] + interval: 2s + timeout: 5s + retries: 30 + volumes: + - ipfs-data:/data/ipfs + + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + ports: + - "127.0.0.1:5432:5432" + command: + - postgres + - -cshared_preload_libraries=pg_stat_statements + - -cmax_connections=200 + environment: + POSTGRES_USER: graph-node + POSTGRES_PASSWORD: launchpad-e2e + POSTGRES_DB: graph-node + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U graph-node -d graph-node"] + interval: 2s + timeout: 5s + retries: 30 + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + ipfs-data: + postgres-data: diff --git a/subgraphs/launchpad/e2e/hardhat.config.ts b/subgraphs/launchpad/e2e/hardhat.config.ts new file mode 100644 index 00000000..d4b90d84 --- /dev/null +++ b/subgraphs/launchpad/e2e/hardhat.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "hardhat/config"; + +export default defineConfig({ + networks: { + hardhatMainnet: { + type: "edr-simulated", + chainType: "l1", + hardfork: "cancun", + blockGasLimit: 60_000_000, + transactionGasCap: 60_000_000, + }, + }, +}); diff --git a/subgraphs/launchpad/e2e/model.ts b/subgraphs/launchpad/e2e/model.ts new file mode 100644 index 00000000..5d81ac8a --- /dev/null +++ b/subgraphs/launchpad/e2e/model.ts @@ -0,0 +1,454 @@ +import assert from "node:assert/strict"; + +export interface IndexedEvent { + name: string; + args: Record; + transactionHash: string; + logIndex: number; + blockNumber: number; + blockHash: string; + timestamp: number; +} + +export interface PoolConfiguration { + address: string; + token0: string; + token1: string; + fee: number; + tickSpacing: number; +} + +export interface ExpectedModelInput { + chainId: bigint; + launchpadAddress: string; + positionManager: string; + protocolRecipient: string; + launchFee: bigint; + defaultSushiFeeBps: number; + protocolReserveBps: number; + events: IndexedEvent[]; + pools: Map; +} + +type Entity = Record & { id: string }; + +export interface EntitySnapshot { + launchpads: Entity[]; + creators: Entity[]; + tokens: Entity[]; + pools: Entity[]; + launchPositions: Entity[]; + feeDistributions: Entity[]; + reserveWithdrawals: Entity[]; + launchFeeWithdrawals: Entity[]; +} + +const lower = (value: unknown): string => String(value).toLowerCase(); +const decimal = (value: unknown): string => BigInt(value as bigint).toString(); +const integer = (value: unknown): number => Number(value); +const relation = (id: string): { id: string } => ({ id }); + +function addressId(chainId: bigint, address: unknown): string { + return `${chainId}:${lower(address)}`; +} + +function eventId(chainId: bigint, event: IndexedEvent): string { + return `${chainId}:${event.transactionHash.toLowerCase()}:${event.logIndex}`; +} + +function positionId( + chainId: bigint, + positionManager: string, + onchainPositionId: unknown +): string { + return `${chainId}:${positionManager.toLowerCase()}:${decimal( + onchainPositionId + )}`; +} + +function metadata(event: IndexedEvent): Record { + return { + transactionHash: event.transactionHash.toLowerCase(), + logIndex: event.logIndex.toString(), + blockNumber: event.blockNumber.toString(), + blockHash: event.blockHash.toLowerCase(), + timestamp: event.timestamp.toString(), + }; +} + +function values(entities: Map): T[] { + return [...entities.values()]; +} + +function add(value: unknown, amount: unknown): string { + return (BigInt(String(value)) + BigInt(amount as bigint)).toString(); +} + +export function buildExpectedSnapshot( + input: ExpectedModelInput +): EntitySnapshot { + const launchpadId = addressId(input.chainId, input.launchpadAddress); + const creators = new Map(); + const tokens = new Map(); + const pools = new Map(); + const launchPositions = new Map(); + const feeDistributions = new Map(); + const reserveWithdrawals = new Map(); + const launchFeeWithdrawals = new Map(); + + const launchpad: Entity = { + id: launchpadId, + chainId: input.chainId.toString(), + address: input.launchpadAddress.toLowerCase(), + addressHex: input.launchpadAddress.toLowerCase(), + positionManager: input.positionManager.toLowerCase(), + positionManagerHex: input.positionManager.toLowerCase(), + protocolRecipient: input.protocolRecipient.toLowerCase(), + protocolRecipientHex: input.protocolRecipient.toLowerCase(), + launchFee: input.launchFee.toString(), + defaultSushiFeeBps: input.defaultSushiFeeBps, + protocolReserveBps: input.protocolReserveBps, + tokenCount: 0, + creatorCount: 0, + positionCount: 0, + tokens: [], + pools: [], + creators: [], + launchFeeWithdrawals: [], + }; + + const orderedEvents = [...input.events].sort( + (left, right) => + left.blockNumber - right.blockNumber || left.logIndex - right.logIndex + ); + + for (const event of orderedEvents) { + const args = event.args; + + if (event.name === "PositionCreated") { + const poolAddress = lower(args.pool); + const poolConfiguration = input.pools.get(poolAddress); + assert( + poolConfiguration, + `Missing pool configuration for ${poolAddress}` + ); + const idForPool = addressId(input.chainId, poolAddress); + let pool = pools.get(idForPool); + if (!pool) { + const launchedToken = lower(args.token); + const tokenIs0 = + poolConfiguration.token0.toLowerCase() === launchedToken; + const tokenIs1 = + poolConfiguration.token1.toLowerCase() === launchedToken; + assert( + tokenIs0 !== tokenIs1, + `Launch token is not exactly one side of pool ${poolAddress}` + ); + pool = { + id: idForPool, + chainId: input.chainId.toString(), + address: poolAddress, + addressHex: poolAddress, + launchpad: relation(launchpadId), + token0: poolConfiguration.token0.toLowerCase(), + token0Hex: poolConfiguration.token0.toLowerCase(), + token1: poolConfiguration.token1.toLowerCase(), + token1Hex: poolConfiguration.token1.toLowerCase(), + fee: poolConfiguration.fee, + tickSpacing: poolConfiguration.tickSpacing, + positionManager: input.positionManager.toLowerCase(), + positionManagerHex: input.positionManager.toLowerCase(), + positionCount: 0, + creationTransactionHash: event.transactionHash.toLowerCase(), + creationTransactionHashHex: event.transactionHash.toLowerCase(), + creationBlockNumber: event.blockNumber.toString(), + creationBlockHash: event.blockHash.toLowerCase(), + creationBlockHashHex: event.blockHash.toLowerCase(), + createdAt: event.timestamp.toString(), + positions: [], + }; + pools.set(idForPool, pool); + } + + const tokenIs0 = + poolConfiguration.token0.toLowerCase() === lower(args.token); + const idForPosition = positionId( + input.chainId, + input.positionManager, + args.positionId + ); + const index = pool.positionCount as number; + const zero = "0"; + const desired = decimal(args.tokenDesired); + const used = decimal(args.tokenUsed); + const position: Entity = { + id: idForPosition, + chainId: input.chainId.toString(), + positionManager: input.positionManager.toLowerCase(), + positionManagerHex: input.positionManager.toLowerCase(), + positionId: decimal(args.positionId), + index, + pool: relation(idForPool), + tickLower: integer(args.tickLower), + tickUpper: integer(args.tickUpper), + liquidity: decimal(args.liquidity), + amount0Desired: tokenIs0 ? desired : zero, + amount1Desired: tokenIs0 ? zero : desired, + amount0: tokenIs0 ? used : zero, + amount1: tokenIs0 ? zero : used, + creationTransactionHash: event.transactionHash.toLowerCase(), + creationTransactionHashHex: event.transactionHash.toLowerCase(), + creationLogIndex: event.logIndex.toString(), + creationBlockNumber: event.blockNumber.toString(), + creationBlockHash: event.blockHash.toLowerCase(), + creationBlockHashHex: event.blockHash.toLowerCase(), + createdAt: event.timestamp.toString(), + }; + launchPositions.set(idForPosition, position); + (pool.positions as Array<{ id: string }>).push(relation(idForPosition)); + pool.positionCount = index + 1; + launchpad.positionCount = (launchpad.positionCount as number) + 1; + continue; + } + + if (event.name === "TokenLaunched") { + const idForToken = addressId(input.chainId, args.token); + const idForCreator = `${launchpadId}:${lower(args.creator)}`; + const idForPool = addressId(input.chainId, args.pool); + const pool = pools.get(idForPool); + assert(pool, `Missing launch pool ${idForPool}`); + const tokenAddress = lower(args.token); + const quoteToken = lower(args.quoteToken); + assert( + (lower(pool.token0) === tokenAddress && + lower(pool.token1) === quoteToken) || + (lower(pool.token1) === tokenAddress && + lower(pool.token0) === quoteToken), + `TokenLaunched pair does not match pool ${idForPool}` + ); + assert.equal( + pool.positionCount, + integer(args.positionCount), + `TokenLaunched position count does not match pool ${idForPool}` + ); + let creator = creators.get(idForCreator); + if (!creator) { + creator = { + id: idForCreator, + chainId: input.chainId.toString(), + address: lower(args.creator), + addressHex: lower(args.creator), + launchpad: relation(launchpadId), + tokenCount: 0, + tokens: [], + }; + creators.set(idForCreator, creator); + launchpad.creatorCount = (launchpad.creatorCount as number) + 1; + } + creator.tokenCount = (creator.tokenCount as number) + 1; + (creator.tokens as Array<{ id: string }>).push(relation(idForToken)); + + const token: Entity = { + id: idForToken, + chainId: input.chainId.toString(), + address: lower(args.token), + addressHex: lower(args.token), + launchpad: relation(launchpadId), + creator: relation(idForCreator), + pool: relation(idForPool), + quoteToken, + quoteTokenHex: quoteToken, + name: String(args.name), + symbol: String(args.symbol), + decimals: integer(args.decimals), + totalSupply: decimal(args.totalSupply), + sushiFeeBps: integer(args.initialSushiFeeBps), + reserveBps: integer(args.reserveBps), + reserveAmount: decimal(args.reserveAmount), + reserveUnlockAt: decimal(args.reserveUnlockAt), + reserveWithdrawn: false, + reserveWithdrawal: null, + totalAmount0Collected: "0", + totalAmount1Collected: "0", + totalAmount0ToSushi: "0", + totalAmount1ToSushi: "0", + totalAmount0ToCreator: "0", + totalAmount1ToCreator: "0", + feeDistributions: [], + creationTransactionHash: event.transactionHash.toLowerCase(), + creationTransactionHashHex: event.transactionHash.toLowerCase(), + creationLogIndex: event.logIndex.toString(), + creationBlockNumber: event.blockNumber.toString(), + creationBlockHash: event.blockHash.toLowerCase(), + creationBlockHashHex: event.blockHash.toLowerCase(), + createdAt: event.timestamp.toString(), + }; + tokens.set(idForToken, token); + launchpad.tokenCount = (launchpad.tokenCount as number) + 1; + continue; + } + + if (event.name === "SushiFeeBpsUpdated") { + const token = tokens.get(addressId(input.chainId, args.token)); + assert( + token, + `Unknown token in SushiFeeBpsUpdated: ${lower(args.token)}` + ); + token.sushiFeeBps = integer(args.newSushiFeeBps); + continue; + } + + if (event.name === "FeesDistributed") { + const idForToken = addressId(input.chainId, args.token); + const idForPool = addressId(input.chainId, args.pool); + const token = tokens.get(idForToken); + const pool = pools.get(idForPool); + assert(token && pool, `Unknown fee distribution target ${idForToken}`); + const id = eventId(input.chainId, event); + const tokenIs0 = lower(pool.token0) === lower(args.token); + const amount0Collected = tokenIs0 + ? decimal(args.tokenCollected) + : decimal(args.quoteCollected); + const amount1Collected = tokenIs0 + ? decimal(args.quoteCollected) + : decimal(args.tokenCollected); + const amount0ToSushi = tokenIs0 + ? decimal(args.tokenToSushi) + : decimal(args.quoteToSushi); + const amount1ToSushi = tokenIs0 + ? decimal(args.quoteToSushi) + : decimal(args.tokenToSushi); + const amount0ToCreator = tokenIs0 + ? decimal(args.tokenToCreator) + : decimal(args.quoteToCreator); + const amount1ToCreator = tokenIs0 + ? decimal(args.quoteToCreator) + : decimal(args.tokenToCreator); + const distribution: Entity = { + id, + chainId: input.chainId.toString(), + token: relation(idForToken), + pool: relation(idForPool), + caller: lower(args.caller), + sushiRecipient: lower(args.protocolRecipient), + creatorRecipient: lower(args.creator), + sushiFeeBps: integer(args.sushiFeeBps), + amount0Collected, + amount1Collected, + amount0ToSushi, + amount1ToSushi, + amount0ToCreator, + amount1ToCreator, + ...metadata(event), + }; + feeDistributions.set(id, distribution); + (token.feeDistributions as Array<{ id: string }>).push(relation(id)); + token.totalAmount0Collected = add( + token.totalAmount0Collected, + amount0Collected + ); + token.totalAmount1Collected = add( + token.totalAmount1Collected, + amount1Collected + ); + token.totalAmount0ToSushi = add( + token.totalAmount0ToSushi, + amount0ToSushi + ); + token.totalAmount1ToSushi = add( + token.totalAmount1ToSushi, + amount1ToSushi + ); + token.totalAmount0ToCreator = add( + token.totalAmount0ToCreator, + amount0ToCreator + ); + token.totalAmount1ToCreator = add( + token.totalAmount1ToCreator, + amount1ToCreator + ); + continue; + } + + if (event.name === "ProtocolReserveWithdrawn") { + const idForToken = addressId(input.chainId, args.token); + const token = tokens.get(idForToken); + assert(token, `Unknown reserve withdrawal target ${idForToken}`); + const id = eventId(input.chainId, event); + const withdrawal: Entity = { + id, + chainId: input.chainId.toString(), + token: relation(idForToken), + recipient: lower(args.recipient), + amount: decimal(args.amount), + ...metadata(event), + }; + reserveWithdrawals.set(id, withdrawal); + token.reserveWithdrawn = true; + token.reserveWithdrawal = relation(id); + continue; + } + + if (event.name === "LaunchFeesWithdrawn") { + const id = eventId(input.chainId, event); + const withdrawal: Entity = { + id, + chainId: input.chainId.toString(), + launchpad: relation(launchpadId), + recipient: lower(args.recipient), + amount: decimal(args.amount), + ...metadata(event), + }; + launchFeeWithdrawals.set(id, withdrawal); + } + } + + launchpad.tokens = values(tokens).map((entity) => relation(entity.id)); + launchpad.pools = values(pools).map((entity) => relation(entity.id)); + launchpad.creators = values(creators).map((entity) => relation(entity.id)); + launchpad.launchFeeWithdrawals = values(launchFeeWithdrawals).map((entity) => + relation(entity.id) + ); + + return normalizeSnapshot({ + launchpads: [launchpad], + creators: values(creators), + tokens: values(tokens), + pools: values(pools), + launchPositions: values(launchPositions), + feeDistributions: values(feeDistributions), + reserveWithdrawals: values(reserveWithdrawals), + launchFeeWithdrawals: values(launchFeeWithdrawals), + }); +} + +function normalizeValue(value: unknown): unknown { + if (Array.isArray(value)) { + const normalized = value.map(normalizeValue); + if ( + normalized.every( + (entry) => entry && typeof entry === "object" && "id" in entry + ) + ) { + normalized.sort((left, right) => + String((left as { id: string }).id).localeCompare( + String((right as { id: string }).id) + ) + ); + } + return normalized; + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + normalizeValue(entry), + ]) + ); + } + return value; +} + +export function normalizeSnapshot(snapshot: EntitySnapshot): EntitySnapshot { + return normalizeValue(snapshot) as EntitySnapshot; +} diff --git a/subgraphs/launchpad/e2e/run.ts b/subgraphs/launchpad/e2e/run.ts new file mode 100644 index 00000000..9f19ba47 --- /dev/null +++ b/subgraphs/launchpad/e2e/run.ts @@ -0,0 +1,1107 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import { + Contract, + ContractFactory, + Interface, + JsonRpcProvider, + type Signer, + type TransactionReceipt, + type TransactionResponse, +} from "ethers"; +import { + buildExpectedSnapshot, + normalizeSnapshot, + type EntitySnapshot, + type IndexedEvent, + type PoolConfiguration, +} from "./model"; +import { + CHAIN_ID, + DEFAULT_SEEDS, + HIGH_QUOTE_TOKEN_ADDRESS, + LOW_QUOTE_TOKEN_ADDRESS, + planScenario, + stringifyPlan, + type LaunchPlan, + type ScenarioAction, + type ScenarioPlan, +} from "./scenario"; + +const RPC_URL = "http://127.0.0.1:8545"; +const GRAPH_ADMIN_URL = "http://127.0.0.1:8020"; +const GRAPH_QUERY_URL = "http://127.0.0.1:8000"; +const IPFS_URL = "http://127.0.0.1:5001"; +const COMPOSE_PROJECT = "launchpad-e2e"; +const REQUIRED_PORTS = [8545, 8000, 8001, 8020, 8030, 8040, 5001, 5432]; + +const packageDirectory = path.resolve(__dirname, ".."); +const repositoryDirectory = path.resolve(packageDirectory, "../.."); +const composeFile = path.join(packageDirectory, "e2e/docker-compose.yml"); +const hardhatConfigSource = path.join( + packageDirectory, + "e2e/hardhat.config.ts" +); + +interface HardhatArtifact { + abi: Array>; + bytecode: string; +} + +interface ContractArtifacts { + launchpad: HardhatArtifact; + launchToken: HardhatArtifact; + quoteToken: HardhatArtifact; + factory: HardhatArtifact; + positionManager: HardhatArtifact; + pool: HardhatArtifact; +} + +interface Fixture { + launchpad: Contract; + launchpadAddress: string; + launchpadDeploymentBlock: number; + quoteTokens: Map; + factory: Contract; + positionManager: Contract; + positionManagerAddress: string; + poolArtifact: HardhatArtifact; + tokenArtifact: HardhatArtifact; +} + +interface LaunchedToken { + tokenKey: string; + address: string; + pool: string; + quoteToken: string; + positionIds: bigint[]; + token: Contract; +} + +interface ScenarioExecution { + events: IndexedEvent[]; + pools: Map; + finalBlock: number; +} + +interface CliOptions { + seeds: number[]; + contractsDirectory: string; +} + +interface RunningProcess { + child: ChildProcess; + finished: Promise; +} + +interface ComposeCli { + command: string; + prefix: string[]; +} + +let composeCli: ComposeCli; + +function parseOptions(): CliOptions { + const arguments_ = process.argv.slice(2); + let seed: number | undefined; + let contractsDirectory = + process.env.LAUNCHPAD_CONTRACTS_DIR ?? + path.resolve(packageDirectory, "../../../sushi-launchpad"); + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--") continue; + if (argument === "--seed") { + const rawSeed = arguments_[index + 1]; + if (!rawSeed) throw new Error("--seed requires a value"); + seed = Number(rawSeed); + index += 1; + continue; + } + if (argument === "--contracts-dir") { + const rawDirectory = arguments_[index + 1]; + if (!rawDirectory) throw new Error("--contracts-dir requires a value"); + contractsDirectory = rawDirectory; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + + if (seed !== undefined) planScenario(seed); + return { + seeds: seed === undefined ? [...DEFAULT_SEEDS] : [seed], + contractsDirectory: path.resolve(contractsDirectory), + }; +} + +function timestampForPath(): string { + return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); +} + +async function runCommand( + command: string, + arguments_: string[], + options: { + cwd: string; + env?: NodeJS.ProcessEnv; + quiet?: boolean; + } +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + output += text; + if (!options.quiet) process.stdout.write(text); + }); + child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + output += text; + if (!options.quiet) process.stderr.write(text); + }); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(output); + else + reject( + new Error( + `${command} ${arguments_.join( + " " + )} exited with code ${code}\n${output}` + ) + ); + }); + }); +} + +function startProcess( + command: string, + arguments_: string[], + cwd: string, + logPath: string +): RunningProcess { + const log = createWriteStream(logPath, { flags: "a" }); + const child = spawn(command, arguments_, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.pipe(log); + child.stderr.pipe(log); + const finished = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => { + log.end(); + resolve(code); + }); + }); + return { child, finished }; +} + +async function stopProcess( + process_: RunningProcess | undefined +): Promise { + if (!process_ || process_.child.exitCode !== null) return; + process_.child.kill("SIGTERM"); + const stopped = await Promise.race([ + process_.finished.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), + ]); + if (!stopped && process_.child.exitCode === null) { + process_.child.kill("SIGKILL"); + await process_.finished; + } +} + +async function portIsAvailable(port: number): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(false); + else reject(error); + }); + server.listen(port, "127.0.0.1", () => { + server.close((error) => (error ? reject(error) : resolve(true))); + }); + }); +} + +async function assertPortsAvailable(): Promise { + const occupied: number[] = []; + for (const port of REQUIRED_PORTS) { + if (!(await portIsAvailable(port))) occupied.push(port); + } + if (occupied.length > 0) { + throw new Error( + `E2E ports already in use: ${occupied.join( + ", " + )}. Stop the conflicting services and retry.` + ); + } +} + +async function detectComposeCli(): Promise { + try { + await runCommand("docker", ["compose", "version"], { + cwd: repositoryDirectory, + quiet: true, + }); + return { command: "docker", prefix: ["compose"] }; + } catch { + try { + await runCommand("docker-compose", ["version"], { + cwd: repositoryDirectory, + quiet: true, + }); + return { command: "docker-compose", prefix: [] }; + } catch { + throw new Error( + "Docker Compose is unavailable. Install either the Docker Compose plugin or docker-compose." + ); + } + } +} + +function composeArguments(...arguments_: string[]): string[] { + return [ + ...composeCli.prefix, + "-f", + composeFile, + "-p", + COMPOSE_PROJECT, + ...arguments_, + ]; +} + +async function waitUntil( + description: string, + timeoutMs: number, + check: () => Promise +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await check()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new Error(`Timed out waiting for ${description}${detail}`); +} + +async function waitForHardhat( + provider: JsonRpcProvider, + process_: RunningProcess +): Promise { + const deadline = Date.now() + 60_000; + let lastError: unknown; + while (Date.now() < deadline) { + if (process_.child.exitCode !== null) { + throw new Error( + `Hardhat exited before its RPC became ready (exit code ${process_.child.exitCode}); see hardhat.log` + ); + } + try { + const network = await provider.getNetwork(); + if (network.chainId === CHAIN_ID) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new Error(`Timed out waiting for Hardhat JSON-RPC${detail}`); +} + +async function waitForGraphNode(): Promise { + await waitUntil("Graph Node admin API", 120_000, async () => { + const response = await fetch(GRAPH_ADMIN_URL); + return response.status < 500; + }); + await waitUntil("IPFS API", 60_000, async () => { + const response = await fetch(`${IPFS_URL}/api/v0/id`, { method: "POST" }); + return response.ok; + }); +} + +async function readArtifact(filePath: string): Promise { + const parsed = JSON.parse( + await readFile(filePath, "utf8") + ) as HardhatArtifact; + assert(Array.isArray(parsed.abi), `Artifact has no ABI: ${filePath}`); + assert( + parsed.bytecode?.startsWith("0x"), + `Artifact has no bytecode: ${filePath}` + ); + return parsed; +} + +async function loadArtifacts( + contractsDirectory: string +): Promise { + const artifact = (...segments: string[]): string => + path.join(contractsDirectory, "artifacts/contracts", ...segments); + return { + launchpad: await readArtifact( + artifact("SushiLaunchpad.sol", "SushiLaunchpad.json") + ), + launchToken: await readArtifact( + artifact("SushiLaunchpadToken.sol", "SushiLaunchpadToken.json") + ), + quoteToken: await readArtifact( + artifact("test/MockSushiV3.sol", "MockERC20.json") + ), + factory: await readArtifact( + artifact("test/MockSushiV3.sol", "MockSushiV3Factory.json") + ), + positionManager: await readArtifact( + artifact("test/MockSushiV3.sol", "MockPositionManager.json") + ), + pool: await readArtifact( + artifact("test/MockSushiV3.sol", "MockSushiV3Pool.json") + ), + }; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +async function assertAbiMatches(artifact: HardhatArtifact): Promise { + const pinned = JSON.parse( + await readFile( + path.join(packageDirectory, "abis/SushiLaunchpad.json"), + "utf8" + ) + ) as unknown; + if (canonicalJson(pinned) !== canonicalJson(artifact.abi)) { + throw new Error( + "The compiled SushiLaunchpad ABI differs from abis/SushiLaunchpad.json. Refresh and review the subgraph ABI before running E2E." + ); + } +} + +async function deploy( + artifact: HardhatArtifact, + signer: Signer, + arguments_: unknown[] = [] +): Promise { + const contract = await new ContractFactory( + artifact.abi, + artifact.bytecode, + signer + ).deploy(...arguments_); + await contract.waitForDeployment(); + return contract as unknown as Contract; +} + +async function deployFixture( + provider: JsonRpcProvider, + signers: Signer[], + artifacts: ContractArtifacts, + plan: ScenarioPlan +): Promise { + const owner = signers[plan.actors.owner]!; + const recipient = signers[plan.actors.protocolRecipients[0]]!; + const quoteTokenTemplate = await deploy(artifacts.quoteToken, owner, [ + "Quote Token", + "QUOTE", + 18, + ]); + const runtimeCode = await provider.getCode( + await quoteTokenTemplate.getAddress() + ); + const quoteTokens = new Map(); + for (const address of [LOW_QUOTE_TOKEN_ADDRESS, HIGH_QUOTE_TOKEN_ADDRESS]) { + await provider.send("hardhat_setCode", [address, runtimeCode]); + quoteTokens.set( + address, + new Contract(address, artifacts.quoteToken.abi, owner) + ); + } + const factory = await deploy(artifacts.factory, owner); + const positionManager = await deploy(artifacts.positionManager, owner, [ + await factory.getAddress(), + LOW_QUOTE_TOKEN_ADDRESS, + ]); + await (await positionManager.setConsumptionBps(plan.consumptionBps)).wait(); + const launchpad = await deploy(artifacts.launchpad, owner, [ + await owner.getAddress(), + await recipient.getAddress(), + await factory.getAddress(), + await positionManager.getAddress(), + ]); + const deploymentReceipt = await launchpad.deploymentTransaction()!.wait(); + assert(deploymentReceipt, "Launchpad deployment did not produce a receipt"); + + return { + launchpad, + launchpadAddress: (await launchpad.getAddress()).toLowerCase(), + launchpadDeploymentBlock: deploymentReceipt.blockNumber, + quoteTokens, + factory, + positionManager, + positionManagerAddress: (await positionManager.getAddress()).toLowerCase(), + poolArtifact: artifacts.pool, + tokenArtifact: artifacts.launchToken, + }; +} + +async function renderManifest( + fixture: Fixture, + seedDirectory: string +): Promise { + const configurationPath = path.join(seedDirectory, "manifest.json"); + await writeFile( + configurationPath, + `${JSON.stringify( + { + launchpad: { + deployments: [ + { + name: "SushiLaunchpad", + network: "hardhat", + chainId: CHAIN_ID.toString(), + address: fixture.launchpadAddress, + startBlock: fixture.launchpadDeploymentBlock, + }, + ], + }, + }, + null, + 2 + )}\n` + ); + const manifest = await runCommand( + "pnpm", + ["exec", "mustache", configurationPath, "template.yaml"], + { cwd: packageDirectory, quiet: true } + ); + await writeFile(path.join(packageDirectory, "subgraph.yaml"), manifest); +} + +async function deploySubgraph(seed: number): Promise { + const name = `sushiswap/launchpad-e2e-${seed}`; + await runCommand("pnpm", ["exec", "graph", "codegen", "subgraph.yaml"], { + cwd: packageDirectory, + }); + await runCommand("pnpm", ["exec", "graph", "build", "subgraph.yaml"], { + cwd: packageDirectory, + }); + await runCommand( + "pnpm", + ["exec", "graph", "create", "--node", GRAPH_ADMIN_URL, name], + { cwd: packageDirectory } + ); + await runCommand( + "pnpm", + [ + "exec", + "graph", + "deploy", + "--node", + GRAPH_ADMIN_URL, + "--ipfs", + IPFS_URL, + "--version-label", + `seed-${seed}`, + name, + "subgraph.yaml", + ], + { cwd: packageDirectory } + ); + return name; +} + +function eventArguments( + parsed: ReturnType +): Record { + assert(parsed, "Expected a parsed Launchpad event"); + return Object.fromEntries( + parsed.fragment.inputs.map((input, index) => [ + input.name, + parsed.args[index], + ]) + ); +} + +async function executeScenario( + provider: JsonRpcProvider, + signers: Signer[], + fixture: Fixture, + plan: ScenarioPlan +): Promise { + const events: IndexedEvent[] = []; + const tokens = new Map(); + const blockTimestamps = new Map(); + const launchpadInterface = new Interface( + fixture.launchpad.interface.fragments + ); + + const recordReceipt = async ( + transaction: TransactionResponse + ): Promise => { + const receipt = await transaction.wait(); + assert(receipt, `Transaction ${transaction.hash} has no receipt`); + let timestamp = blockTimestamps.get(receipt.blockNumber); + if (timestamp === undefined) { + const block = await provider.getBlock(receipt.blockNumber); + assert(block, `Missing block ${receipt.blockNumber}`); + timestamp = block.timestamp; + blockTimestamps.set(receipt.blockNumber, timestamp); + } + + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== fixture.launchpadAddress) continue; + const parsed = launchpadInterface.parseLog({ + topics: [...log.topics], + data: log.data, + }); + if (!parsed) continue; + events.push({ + name: parsed.name, + args: eventArguments(parsed), + transactionHash: receipt.hash.toLowerCase(), + logIndex: log.index, + blockNumber: receipt.blockNumber, + blockHash: receipt.blockHash.toLowerCase(), + timestamp, + }); + } + return receipt; + }; + + const sendLaunch = async ( + action: LaunchPlan + ): Promise => { + const block = await provider.getBlock("latest"); + assert(block, "Hardhat returned no latest block"); + const currentLaunchFee = BigInt(await fixture.launchpad.launchFee()); + assert.equal( + currentLaunchFee, + action.launchFee, + `Planned launch fee drifted for ${action.tokenKey}` + ); + const connected = fixture.launchpad.connect( + signers[action.creatorAccount]! + ) as Contract; + return await connected.launch( + { name: action.name, symbol: action.symbol }, + action.quoteToken, + action.ranges, + BigInt(block.timestamp + 3_600), + { value: action.launchFee } + ); + }; + + const finalizeLaunch = async ( + action: LaunchPlan, + transaction: TransactionResponse + ): Promise => { + const receipt = await recordReceipt(transaction); + const launchEvents = events.filter( + (event) => + event.transactionHash === receipt.hash.toLowerCase() && + event.name === "TokenLaunched" + ); + assert.equal( + launchEvents.length, + 1, + `Missing TokenLaunched for ${action.tokenKey}` + ); + const launchEvent = launchEvents[0]!; + const quoteToken = String(launchEvent.args.quoteToken).toLowerCase(); + assert.equal( + quoteToken, + action.quoteToken, + `TokenLaunched quote token drifted for ${action.tokenKey}` + ); + assert( + fixture.quoteTokens.has(quoteToken), + `Unknown quote token for ${action.tokenKey}: ${quoteToken}` + ); + const positionEvents = events.filter( + (event) => + event.transactionHash === receipt.hash.toLowerCase() && + event.name === "PositionCreated" + ); + assert.equal( + positionEvents.length, + action.ranges.length, + `Unexpected position count for ${action.tokenKey}` + ); + const address = String(launchEvent.args.token).toLowerCase(); + tokens.set(action.tokenKey, { + tokenKey: action.tokenKey, + address, + pool: String(launchEvent.args.pool).toLowerCase(), + quoteToken, + positionIds: positionEvents.map((event) => + BigInt(event.args.positionId as bigint) + ), + token: new Contract(address, fixture.tokenArtifact.abi, signers[0]), + }); + }; + + const transact = async ( + action: Exclude + ) => { + if (action.kind === "launch") { + const transaction = await sendLaunch(action); + await finalizeLaunch(action, transaction); + return; + } + if (action.kind === "setDefaultSushiFeeBps") { + await recordReceipt( + await fixture.launchpad.setDefaultSushiFeeBps(action.value) + ); + return; + } + if (action.kind === "setProtocolReserveBps") { + await recordReceipt( + await fixture.launchpad.setProtocolReserveBps(action.value) + ); + return; + } + if (action.kind === "setProtocolRecipient") { + await recordReceipt( + await fixture.launchpad.setProtocolRecipient( + await signers[action.account]!.getAddress() + ) + ); + return; + } + if (action.kind === "setLaunchFee") { + await recordReceipt(await fixture.launchpad.setLaunchFee(action.value)); + return; + } + if (action.kind === "setTokenSushiFeeBps") { + const token = tokens.get(action.tokenKey); + assert(token, `Unknown planned token ${action.tokenKey}`); + await recordReceipt( + await fixture.launchpad.setSushiFeeBps(token.address, action.value) + ); + return; + } + if (action.kind === "distributeFees") { + const token = tokens.get(action.tokenKey); + assert(token, `Unknown planned token ${action.tokenKey}`); + const pool = new Contract( + token.pool, + fixture.poolArtifact.abi, + signers[0] + ); + const tokenIs0 = + String(await pool.token0()).toLowerCase() === + token.address.toLowerCase(); + const quoteToken = fixture.quoteTokens.get(token.quoteToken); + assert(quoteToken, `Unknown quote token ${token.quoteToken}`); + await ( + await quoteToken.mint( + fixture.positionManagerAddress, + action.quoteAmount + ) + ).wait(); + await ( + await fixture.positionManager.seedFees( + token.positionIds[action.positionIndex], + tokenIs0 ? action.tokenAmount : action.quoteAmount, + tokenIs0 ? action.quoteAmount : action.tokenAmount + ) + ).wait(); + const connected = fixture.launchpad.connect( + signers[action.callerAccount]! + ) as Contract; + await recordReceipt(await connected.distributeFees(token.address)); + return; + } + if (action.kind === "withdrawLaunchFees") { + await recordReceipt(await fixture.launchpad.withdrawLaunchFees()); + return; + } + if (action.kind === "advanceTime") { + await provider.send("evm_increaseTime", [action.seconds]); + await provider.send("evm_mine", []); + return; + } + if (action.kind === "withdrawReserve") { + const token = tokens.get(action.tokenKey); + assert(token, `Unknown planned token ${action.tokenKey}`); + await recordReceipt( + await fixture.launchpad.withdrawProtocolReserve(token.address) + ); + return; + } + const exhaustive: never = action; + throw new Error(`Unhandled action ${(exhaustive as ScenarioAction).kind}`); + }; + + for (const action of plan.actions) { + if (action.kind !== "sameBlock") { + await transact(action); + continue; + } + + const [launch, defaultUpdate, reserveUpdate] = action.actions; + await provider.send("evm_setAutomine", [false]); + try { + const launchTransaction = await sendLaunch(launch); + const ownerAddress = await signers[0]!.getAddress(); + const ownerNonce = await provider.getTransactionCount( + ownerAddress, + "pending" + ); + const defaultTransaction = await fixture.launchpad.setDefaultSushiFeeBps( + defaultUpdate.value, + { nonce: ownerNonce } + ); + const reserveTransaction = await fixture.launchpad.setProtocolReserveBps( + reserveUpdate.value, + { nonce: ownerNonce + 1 } + ); + await provider.send("evm_mine", []); + await finalizeLaunch(launch, launchTransaction); + await recordReceipt(defaultTransaction); + await recordReceipt(reserveTransaction); + } finally { + await provider.send("evm_setAutomine", [true]); + } + } + + const pools = new Map(); + for (const token of tokens.values()) { + if (pools.has(token.pool)) continue; + const contract = new Contract( + token.pool, + fixture.poolArtifact.abi, + signers[0] + ); + pools.set(token.pool, { + address: token.pool, + token0: String(await contract.token0()).toLowerCase(), + token1: String(await contract.token1()).toLowerCase(), + fee: Number(await contract.fee()), + tickSpacing: Number(await contract.tickSpacing()), + }); + } + + return { + events, + pools, + finalBlock: await provider.getBlockNumber(), + }; +} + +async function graphQl(endpoint: string, query: string): Promise { + const response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query }), + }); + if (!response.ok) { + throw new Error( + `GraphQL HTTP ${response.status}: ${await response.text()}` + ); + } + const payload = (await response.json()) as { + data?: T; + errors?: Array<{ message: string }>; + }; + if (payload.errors?.length || !payload.data) { + throw new Error( + `GraphQL query failed: ${ + payload.errors?.map((error) => error.message).join("; ") ?? "no data" + }` + ); + } + return payload.data; +} + +async function waitForIndexedHead( + endpoint: string, + finalBlock: number +): Promise { + await waitUntil(`subgraph head block ${finalBlock}`, 120_000, async () => { + const data = await graphQl<{ + _meta: { block: { number: number }; hasIndexingErrors: boolean } | null; + }>( + endpoint, + "query E2EHead { _meta { block { number } hasIndexingErrors } }" + ); + if (data._meta?.hasIndexingErrors) { + throw new Error("Subgraph reports indexing errors"); + } + return (data._meta?.block.number ?? -1) >= finalBlock; + }); +} + +const snapshotQuery = ` + query E2ESnapshot { + launchpads(first: 1000, orderBy: id) { + id chainId address addressHex positionManager positionManagerHex + protocolRecipient protocolRecipientHex launchFee + defaultSushiFeeBps protocolReserveBps tokenCount creatorCount positionCount + tokens { id } pools { id } creators { id } launchFeeWithdrawals { id } + } + creators(first: 1000, orderBy: id) { + id chainId address addressHex launchpad { id } tokenCount tokens { id } + } + tokens(first: 1000, orderBy: id) { + id chainId address addressHex launchpad { id } creator { id } pool { id } + quoteToken quoteTokenHex name symbol decimals totalSupply sushiFeeBps + reserveBps reserveAmount + reserveUnlockAt reserveWithdrawn reserveWithdrawal { id } + totalAmount0Collected totalAmount1Collected totalAmount0ToSushi + totalAmount1ToSushi totalAmount0ToCreator totalAmount1ToCreator + feeDistributions { id } + creationTransactionHash creationTransactionHashHex creationLogIndex + creationBlockNumber creationBlockHash creationBlockHashHex createdAt + } + pools(first: 1000, orderBy: id) { + id chainId address addressHex launchpad { id } positions { id } + token0 token0Hex token1 token1Hex fee tickSpacing + positionManager positionManagerHex positionCount + creationTransactionHash creationTransactionHashHex creationBlockNumber + creationBlockHash creationBlockHashHex createdAt + } + launchPositions(first: 1000, orderBy: id) { + id chainId positionManager positionManagerHex positionId index pool { id } + tickLower tickUpper liquidity amount0Desired amount1Desired amount0 amount1 + creationTransactionHash creationTransactionHashHex creationLogIndex + creationBlockNumber creationBlockHash creationBlockHashHex createdAt + } + feeDistributions(first: 1000, orderBy: id) { + id chainId token { id } pool { id } caller sushiRecipient creatorRecipient + sushiFeeBps amount0Collected amount1Collected amount0ToSushi amount1ToSushi + amount0ToCreator amount1ToCreator transactionHash logIndex blockNumber + blockHash timestamp + } + reserveWithdrawals(first: 1000, orderBy: id) { + id chainId token { id } recipient amount transactionHash logIndex + blockNumber blockHash timestamp + } + launchFeeWithdrawals(first: 1000, orderBy: id) { + id chainId launchpad { id } recipient amount transactionHash logIndex + blockNumber blockHash timestamp + } + } +`; + +async function runSeed( + seed: number, + provider: JsonRpcProvider, + signers: Signer[], + artifacts: ContractArtifacts, + runDirectory: string +): Promise { + const seedDirectory = path.join(runDirectory, `seed-${seed}`); + await mkdir(seedDirectory, { recursive: true }); + const plan = planScenario(seed); + await writeFile(path.join(seedDirectory, "plan.json"), stringifyPlan(plan)); + process.stdout.write(`\nSeed ${seed}: deploying fixture\n`); + const fixture = await deployFixture(provider, signers, artifacts, plan); + await renderManifest(fixture, seedDirectory); + const subgraphName = await deploySubgraph(seed); + const endpoint = `${GRAPH_QUERY_URL}/subgraphs/name/${subgraphName}`; + + process.stdout.write( + `Seed ${seed}: executing ${plan.actions.length} actions\n` + ); + const execution = await executeScenario(provider, signers, fixture, plan); + await writeFile( + path.join(seedDirectory, "trace.json"), + `${JSON.stringify( + execution.events, + (_key, value: unknown) => + typeof value === "bigint" ? value.toString() : value, + 2 + )}\n` + ); + await waitForIndexedHead(endpoint, execution.finalBlock); + + const actual = normalizeSnapshot( + await graphQl(endpoint, snapshotQuery) + ); + const expected = buildExpectedSnapshot({ + chainId: CHAIN_ID, + launchpadAddress: fixture.launchpadAddress, + positionManager: fixture.positionManagerAddress, + protocolRecipient: String(await fixture.launchpad.protocolRecipient()), + launchFee: BigInt(await fixture.launchpad.launchFee()), + defaultSushiFeeBps: Number(await fixture.launchpad.defaultSushiFeeBps()), + protocolReserveBps: Number(await fixture.launchpad.protocolReserveBps()), + events: execution.events, + pools: execution.pools, + }); + await writeFile( + path.join(seedDirectory, "graphql.json"), + `${JSON.stringify(actual, null, 2)}\n` + ); + await writeFile( + path.join(seedDirectory, "expected.json"), + `${JSON.stringify(expected, null, 2)}\n` + ); + assert.deepStrictEqual( + actual, + expected, + `Subgraph projection mismatch for seed ${seed}` + ); + process.stdout.write(`Seed ${seed}: projection verified\n`); +} + +async function captureComposeLogs(runDirectory: string): Promise { + try { + const logs = await runCommand( + composeCli.command, + composeArguments("logs", "--no-color"), + { cwd: repositoryDirectory, quiet: true } + ); + await writeFile(path.join(runDirectory, "docker-compose.log"), logs); + } catch (error) { + await writeFile( + path.join(runDirectory, "docker-compose.log"), + `${error instanceof Error ? error.stack : String(error)}\n` + ); + } +} + +async function composeDown(): Promise { + await runCommand( + composeCli.command, + composeArguments("down", "--volumes", "--remove-orphans"), + { cwd: repositoryDirectory, quiet: true } + ).catch(() => undefined); +} + +async function main(): Promise { + const options = parseOptions(); + options.contractsDirectory = await realpath(options.contractsDirectory).catch( + () => { + throw new Error( + `Sushi Launchpad checkout not found at ${options.contractsDirectory}. Set LAUNCHPAD_CONTRACTS_DIR or pass --contracts-dir.` + ); + } + ); + const runDirectory = path.join( + packageDirectory, + ".e2e-artifacts", + timestampForPath() + ); + await mkdir(runDirectory, { recursive: true }); + composeCli = await detectComposeCli(); + await assertPortsAvailable(); + + process.stdout.write( + "Compiling the configured Launchpad contract checkout\n" + ); + await runCommand("pnpm", ["compile"], { + cwd: options.contractsDirectory, + }); + const artifacts = await loadArtifacts(options.contractsDirectory); + await assertAbiMatches(artifacts.launchpad); + + let hardhat: RunningProcess | undefined; + let composeStarted = false; + let cleaningUp = false; + const runtimeHardhatConfig = path.join( + options.contractsDirectory, + "cache/launchpad-e2e.config.ts" + ); + await mkdir(path.dirname(runtimeHardhatConfig), { recursive: true }); + await writeFile( + runtimeHardhatConfig, + await readFile(hardhatConfigSource, "utf8") + ); + const cleanup = async (): Promise => { + if (cleaningUp) return; + cleaningUp = true; + if (composeStarted) await captureComposeLogs(runDirectory); + if (composeStarted) await composeDown(); + await stopProcess(hardhat); + await rm(runtimeHardhatConfig, { force: true }); + }; + const interrupt = (signal: NodeJS.Signals) => { + void cleanup().finally(() => { + process.kill(process.pid, signal); + }); + }; + process.once("SIGINT", interrupt); + process.once("SIGTERM", interrupt); + + try { + hardhat = startProcess( + "pnpm", + [ + "exec", + "hardhat", + "--config", + runtimeHardhatConfig, + "--network", + "hardhatMainnet", + "node", + "--hostname", + "0.0.0.0", + ], + options.contractsDirectory, + path.join(runDirectory, "hardhat.log") + ); + const provider = new JsonRpcProvider(RPC_URL); + await waitForHardhat(provider, hardhat); + + composeStarted = true; + await runCommand(composeCli.command, composeArguments("up", "-d"), { + cwd: repositoryDirectory, + }); + await waitForGraphNode(); + + const signers: Signer[] = []; + for (let index = 0; index < 8; index += 1) { + signers.push(await provider.getSigner(index)); + } + for (const seed of options.seeds) { + await runSeed(seed, provider, signers, artifacts, runDirectory); + } + + process.stdout.write( + `\nAll ${options.seeds.length} E2E seed(s) passed. Artifacts: ${runDirectory}\n` + ); + } catch (error) { + process.stderr.write( + `\nE2E failed. Diagnostics: ${runDirectory}\n${ + error instanceof Error ? error.stack : String(error) + }\n` + ); + process.exitCode = 1; + } finally { + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", interrupt); + await cleanup(); + } +} + +void main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.stack : String(error)}\n` + ); + process.exitCode = 1; +}); diff --git a/subgraphs/launchpad/e2e/scenario.test.ts b/subgraphs/launchpad/e2e/scenario.test.ts new file mode 100644 index 00000000..629e7fae --- /dev/null +++ b/subgraphs/launchpad/e2e/scenario.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_SEEDS, + planScenario, + stringifyPlan, + validateScenario, +} from "./scenario"; + +test("the same seed always produces the same complete plan", () => { + const first = stringifyPlan(planScenario(202)); + const second = stringifyPlan(planScenario(202)); + + assert.equal(first, second); +}); + +test("different seeds vary the planned real-world behavior", () => { + const plans = DEFAULT_SEEDS.map((seed) => stringifyPlan(planScenario(seed))); + + assert.equal(new Set(plans).size, DEFAULT_SEEDS.length); +}); + +test("the default matrix satisfies all scenario invariants", () => { + for (const seed of DEFAULT_SEEDS) { + const plan = planScenario(seed); + assert.doesNotThrow(() => validateScenario(plan)); + const launches = plan.actions + .flatMap((action) => + action.kind === "sameBlock" ? action.actions : [action] + ) + .filter((action) => action.kind === "launch"); + assert.equal(new Set(launches.map((launch) => launch.quoteToken)).size, 2); + } +}); + +test("invalid seeds fail before any chain process starts", () => { + assert.throws(() => planScenario(-1), /unsigned 32-bit integer/); + assert.throws(() => planScenario(2 ** 32), /unsigned 32-bit integer/); + assert.throws(() => planScenario(1.5), /unsigned 32-bit integer/); +}); diff --git a/subgraphs/launchpad/e2e/scenario.ts b/subgraphs/launchpad/e2e/scenario.ts new file mode 100644 index 00000000..532a5a86 --- /dev/null +++ b/subgraphs/launchpad/e2e/scenario.ts @@ -0,0 +1,356 @@ +export const DEFAULT_SEEDS = [101, 202, 303] as const; + +export const CHAIN_ID = 31_337n; +export const UNIT = 10n ** 18n; +export const TOKEN_SUPPLY = 1_000_000_000n * UNIT; +export const INITIAL_LAUNCH_FEE = 500_000_000_000_000n; +export const INITIAL_SUSHI_FEE_BPS = 7_000; +export const INITIAL_RESERVE_BPS = 300; +export const RESERVE_LOCK_SECONDS = 365 * 24 * 60 * 60; + +export const LOW_QUOTE_TOKEN_ADDRESS = + "0x0000000000000000000000000000000000001000"; +export const HIGH_QUOTE_TOKEN_ADDRESS = + "0xfffffffffffffffffffffffffffffffffffffffe"; + +export interface SaleRangePlan { + startTick: number; + endTick: number; + amount: bigint; +} + +export interface LaunchPlan { + kind: "launch"; + tokenKey: string; + creatorAccount: number; + quoteToken: string; + name: string; + symbol: string; + ranges: SaleRangePlan[]; + launchFee: bigint; +} + +export type ScenarioAction = + | LaunchPlan + | { + kind: "sameBlock"; + actions: [ + LaunchPlan, + { kind: "setDefaultSushiFeeBps"; value: number }, + { kind: "setProtocolReserveBps"; value: number } + ]; + } + | { kind: "setDefaultSushiFeeBps"; value: number } + | { kind: "setProtocolReserveBps"; value: number } + | { kind: "setProtocolRecipient"; account: number } + | { kind: "setLaunchFee"; value: bigint } + | { kind: "setTokenSushiFeeBps"; tokenKey: string; value: number } + | { + kind: "distributeFees"; + tokenKey: string; + callerAccount: number; + positionIndex: number; + quoteAmount: bigint; + tokenAmount: bigint; + } + | { kind: "withdrawLaunchFees" } + | { kind: "advanceTime"; seconds: number } + | { kind: "withdrawReserve"; tokenKey: string }; + +export interface ScenarioPlan { + seed: number; + consumptionBps: number; + actors: { + owner: 0; + protocolRecipients: [1, 2]; + creators: [3, 4, 5]; + callers: [6, 7]; + }; + actions: ScenarioAction[]; + intentionallyUnwithdrawnToken: string; +} + +class SeededRandom { + private state: number; + + constructor(seed: number) { + this.state = seed >>> 0; + } + + next(): number { + this.state = (this.state + 0x6d2b79f5) >>> 0; + let value = this.state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + } + + int(min: number, max: number): number { + return min + Math.floor(this.next() * (max - min + 1)); + } + + pick(values: readonly T[]): T { + return values[this.int(0, values.length - 1)]!; + } + + shuffle(values: readonly T[]): T[] { + const shuffled = [...values]; + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const other = this.int(0, index); + [shuffled[index], shuffled[other]] = [shuffled[other]!, shuffled[index]!]; + } + return shuffled; + } +} + +function launchPlan( + random: SeededRandom, + index: number, + creatorAccount: number +): LaunchPlan { + const rangeCount = random.int(1, 4); + const firstTick = -rangeCount * 200; + const ranges: SaleRangePlan[] = []; + + for (let rangeIndex = 0; rangeIndex < rangeCount; rangeIndex += 1) { + ranges.push({ + startTick: firstTick + rangeIndex * 200, + endTick: firstTick + (rangeIndex + 1) * 200, + amount: BigInt(random.int(80, 140)) * 1_000_000n * UNIT, + }); + } + + return { + kind: "launch", + tokenKey: `token-${index}`, + creatorAccount, + quoteToken: + index % 2 === 0 ? HIGH_QUOTE_TOKEN_ADDRESS : LOW_QUOTE_TOKEN_ADDRESS, + name: `Launchpad Agent ${index + 1}`, + symbol: `LPA${index + 1}`, + ranges, + launchFee: INITIAL_LAUNCH_FEE, + }; +} + +function distribution( + random: SeededRandom, + tokenKey: string, + callerAccount: number, + maxPositionIndex: number +): ScenarioAction { + return { + kind: "distributeFees", + tokenKey, + callerAccount, + positionIndex: random.int(0, maxPositionIndex), + quoteAmount: BigInt(random.int(51, 500)), + tokenAmount: BigInt(random.int(51, 500)), + }; +} + +export function planScenario(seed: number): ScenarioPlan { + if (!Number.isInteger(seed) || seed < 0 || seed > 0xffff_ffff) { + throw new Error( + `Seed must be an unsigned 32-bit integer, received ${seed}` + ); + } + + const random = new SeededRandom(seed); + const creators = random.shuffle([3, 3, 4, 5]); + const launches = creators.map((creator, index) => + launchPlan(random, index, creator) + ); + const newDefault = random.pick([1_500, 2_500, 4_500, 6_000, 8_000]); + const newReserve = random.pick([100, 500, 750, 1_000]); + const finalDefault = random.pick( + [1_000, 3_500, 5_500, 9_000].filter((value) => value !== newDefault) + ); + const finalReserve = random.pick( + [0, 200, 600, 900].filter((value) => value !== newReserve) + ); + const newLaunchFee = random.pick([ + 100_000_000_000_000n, + 700_000_000_000_000n, + 1_000_000_000_000_000n, + ]); + + const firstLaunch = launches[0]!; + const remaining = random.shuffle(launches.slice(1)); + const firstRemaining = remaining[0]!; + const secondRemaining = remaining[1]!; + const finalLaunch = remaining[2]!; + + const actions: ScenarioAction[] = [ + { + kind: "sameBlock", + actions: [ + firstLaunch, + { kind: "setDefaultSushiFeeBps", value: newDefault }, + { kind: "setProtocolReserveBps", value: newReserve }, + ], + }, + ]; + + if (random.int(0, 1) === 0) { + actions.push({ kind: "setLaunchFee", value: newLaunchFee }); + firstRemaining.launchFee = newLaunchFee; + secondRemaining.launchFee = newLaunchFee; + finalLaunch.launchFee = newLaunchFee; + actions.push(firstRemaining, secondRemaining); + } else { + actions.push(firstRemaining); + actions.push({ kind: "setLaunchFee", value: newLaunchFee }); + secondRemaining.launchFee = newLaunchFee; + finalLaunch.launchFee = newLaunchFee; + actions.push(secondRemaining); + } + + actions.push({ kind: "withdrawLaunchFees" }); + actions.push(finalLaunch); + actions.push({ kind: "withdrawLaunchFees" }); + actions.push( + { kind: "setDefaultSushiFeeBps", value: finalDefault }, + { kind: "setProtocolReserveBps", value: finalReserve } + ); + + const firstTokenFee = random.pick([1_234, 3_333, 5_000, 8_765]); + const secondTokenFee = random.pick([777, 2_222, 6_666, 9_999]); + actions.push({ + kind: "setTokenSushiFeeBps", + tokenKey: firstLaunch.tokenKey, + value: firstTokenFee, + }); + actions.push( + distribution( + random, + firstLaunch.tokenKey, + random.pick([6, 7]), + firstLaunch.ranges.length - 1 + ) + ); + actions.push({ kind: "setProtocolRecipient", account: 2 }); + actions.push( + distribution( + random, + firstLaunch.tokenKey, + random.pick([6, 7]), + firstLaunch.ranges.length - 1 + ) + ); + actions.push({ + kind: "setTokenSushiFeeBps", + tokenKey: firstRemaining.tokenKey, + value: secondTokenFee, + }); + actions.push( + distribution( + random, + firstRemaining.tokenKey, + random.pick([6, 7]), + firstRemaining.ranges.length - 1 + ) + ); + + const tokenKeys = launches.map((launch) => launch.tokenKey); + const intentionallyUnwithdrawnToken = random.pick(tokenKeys); + const withdrawals = random.shuffle( + tokenKeys.filter((tokenKey) => tokenKey !== intentionallyUnwithdrawnToken) + ); + actions.push({ + kind: "advanceTime", + seconds: RESERVE_LOCK_SECONDS + 100, + }); + for (const tokenKey of withdrawals) { + actions.push({ kind: "withdrawReserve", tokenKey }); + } + + const plan: ScenarioPlan = { + seed, + consumptionBps: [10_000, 9_999, 9_750][seed % 3]!, + actors: { + owner: 0, + protocolRecipients: [1, 2], + creators: [3, 4, 5], + callers: [6, 7], + }, + actions, + intentionallyUnwithdrawnToken, + }; + + validateScenario(plan); + return plan; +} + +export function validateScenario(plan: ScenarioPlan): void { + const flattened = plan.actions.flatMap((action) => + action.kind === "sameBlock" ? action.actions : [action] + ); + const launches = flattened.filter( + (action): action is LaunchPlan => action.kind === "launch" + ); + const launchKeys = new Set(launches.map((launch) => launch.tokenKey)); + + if (launches.length !== 4 || launchKeys.size !== 4) { + throw new Error("Every scenario must contain four unique launches"); + } + if (new Set(launches.map((launch) => launch.creatorAccount)).size !== 3) { + throw new Error("Every scenario must contain exactly three creators"); + } + const quoteTokens = new Set(launches.map((launch) => launch.quoteToken)); + if ( + quoteTokens.size !== 2 || + !quoteTokens.has(LOW_QUOTE_TOKEN_ADDRESS) || + !quoteTokens.has(HIGH_QUOTE_TOKEN_ADDRESS) + ) { + throw new Error( + "Every scenario must launch against both quote-token orderings" + ); + } + if (!launchKeys.has(plan.intentionallyUnwithdrawnToken)) { + throw new Error("The intentionally unwithdrawn token must be launched"); + } + + for (const launch of launches) { + if (launch.ranges.length < 1 || launch.ranges.length > 16) { + throw new Error(`Invalid range count for ${launch.tokenKey}`); + } + let amountSum = 0n; + for (let index = 0; index < launch.ranges.length; index += 1) { + const range = launch.ranges[index]!; + if ( + range.amount <= 0n || + range.startTick >= range.endTick || + range.startTick % 200 !== 0 || + range.endTick % 200 !== 0 || + (index > 0 && range.startTick !== launch.ranges[index - 1]!.endTick) + ) { + throw new Error(`Invalid range ${index} for ${launch.tokenKey}`); + } + amountSum += range.amount; + } + if (amountSum > (TOKEN_SUPPLY * 9_000n) / 10_000n) { + throw new Error(`Range allocation is unsafe for ${launch.tokenKey}`); + } + } + + const count = (kind: ScenarioAction["kind"]): number => + flattened.filter((action) => action.kind === kind).length; + if ( + count("distributeFees") !== 3 || + count("withdrawLaunchFees") !== 2 || + count("withdrawReserve") !== 3 || + count("setProtocolRecipient") !== 1 + ) { + throw new Error("Scenario is missing required lifecycle coverage"); + } +} + +export function stringifyPlan(plan: ScenarioPlan): string { + return `${JSON.stringify( + plan, + (_key, value: unknown) => + typeof value === "bigint" ? value.toString() : value, + 2 + )}\n`; +} diff --git a/subgraphs/launchpad/e2e/tsconfig.json b/subgraphs/launchpad/e2e/tsconfig.json new file mode 100644 index 00000000..bc03dcc0 --- /dev/null +++ b/subgraphs/launchpad/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "files": ["scenario.ts", "scenario.test.ts", "model.ts", "run.ts"] +} diff --git a/subgraphs/launchpad/package.json b/subgraphs/launchpad/package.json new file mode 100644 index 00000000..cccf2bdc --- /dev/null +++ b/subgraphs/launchpad/package.json @@ -0,0 +1,30 @@ +{ + "name": "launchpad", + "license": "MIT", + "repository": { + "url": "sushiswap/subgraphs", + "directory": "subgraphs/launchpad" + }, + "files": [ + "generated" + ], + "scripts": { + "generate": "mustache ../../config/$NETWORK.js template.yaml > subgraph.yaml && graph codegen", + "build": "graph build", + "test": "graph test -r", + "test:e2e": "tsx e2e/run.ts", + "test:e2e:planner": "tsx --test e2e/scenario.test.ts", + "test:e2e:typecheck": "tsc -p e2e/tsconfig.json" + }, + "devDependencies": { + "@graphprotocol/graph-cli": "^0.96.0", + "@graphprotocol/graph-ts": "^0.27.0", + "@types/node": "^22.13.11", + "assemblyscript": "^0.19.20", + "ethers": "^6.14.0", + "matchstick-as": "0.6.0", + "tsx": "^4.20.3", + "typescript": "^5.8.2", + "wabt": "1.0.24" + } +} diff --git a/subgraphs/launchpad/schema.graphql b/subgraphs/launchpad/schema.graphql new file mode 100644 index 00000000..c1bcfeef --- /dev/null +++ b/subgraphs/launchpad/schema.graphql @@ -0,0 +1,230 @@ +type Launchpad @entity { + # Identity + id: ID! + chainId: BigInt! + address: Bytes! + addressHex: String! + + # Deployment configuration + positionManager: Bytes! + positionManagerHex: String! + + # Current protocol configuration + protocolRecipient: Bytes! + protocolRecipientHex: String! + launchFee: BigInt! + defaultSushiFeeBps: Int! + protocolReserveBps: Int! + + # Aggregate metrics + tokenCount: Int! + creatorCount: Int! + positionCount: Int! + + # Relationships + tokens: [Token!]! @derivedFrom(field: "launchpad") + pools: [Pool!]! @derivedFrom(field: "launchpad") + creators: [Creator!]! @derivedFrom(field: "launchpad") + launchFeeWithdrawals: [LaunchFeeWithdrawal!]! @derivedFrom(field: "launchpad") +} + +type Creator @entity { + # Identity + id: ID! + chainId: BigInt! + address: Bytes! + addressHex: String! + + # Relationships + launchpad: Launchpad! + tokens: [Token!]! @derivedFrom(field: "creator") + + # Aggregate metrics + tokenCount: Int! +} + +type Token @entity { + # Identity + id: ID! + chainId: BigInt! + address: Bytes! + addressHex: String! + + # Relationships + launchpad: Launchpad! + creator: Creator! + pool: Pool! + quoteToken: Bytes! + quoteTokenHex: String! + + # ERC-20 metadata + name: String! + symbol: String! + decimals: Int! + totalSupply: BigInt! + + # Current fee configuration + sushiFeeBps: Int! + + # Reserve state + reserveBps: Int! + reserveAmount: BigInt! + reserveUnlockAt: BigInt! + reserveWithdrawn: Boolean! + reserveWithdrawal: ReserveWithdrawal + + # Fee distribution metrics + totalAmount0Collected: BigInt! + totalAmount1Collected: BigInt! + totalAmount0ToSushi: BigInt! + totalAmount1ToSushi: BigInt! + totalAmount0ToCreator: BigInt! + totalAmount1ToCreator: BigInt! + feeDistributions: [FeeDistribution!]! @derivedFrom(field: "token") + + # Creation metadata + creationTransactionHash: Bytes! + creationTransactionHashHex: String! + creationLogIndex: BigInt! + creationBlockNumber: BigInt! + creationBlockHash: Bytes! + creationBlockHashHex: String! + createdAt: BigInt! +} + +type Pool @entity { + # Identity + id: ID! + chainId: BigInt! + address: Bytes! + addressHex: String! + + # Relationships + launchpad: Launchpad! + positions: [LaunchPosition!]! @derivedFrom(field: "pool") + + # Immutable pool configuration + token0: Bytes! + token0Hex: String! + token1: Bytes! + token1Hex: String! + fee: Int! + tickSpacing: Int! + positionManager: Bytes! + positionManagerHex: String! + + # Aggregate metrics + positionCount: Int! + + # Creation metadata + creationTransactionHash: Bytes! + creationTransactionHashHex: String! + creationBlockNumber: BigInt! + creationBlockHash: Bytes! + creationBlockHashHex: String! + createdAt: BigInt! +} + +type LaunchPosition @entity(immutable: true) { + # Identity + id: ID! + chainId: BigInt! + positionManager: Bytes! + positionManagerHex: String! + positionId: BigInt! + index: Int! + + # Relationships + pool: Pool! + + # Position configuration + tickLower: Int! + tickUpper: Int! + liquidity: BigInt! + + # Initial amounts + amount0Desired: BigInt! + amount1Desired: BigInt! + amount0: BigInt! + amount1: BigInt! + + # Creation metadata + creationTransactionHash: Bytes! + creationTransactionHashHex: String! + creationLogIndex: BigInt! + creationBlockNumber: BigInt! + creationBlockHash: Bytes! + creationBlockHashHex: String! + createdAt: BigInt! +} + +type FeeDistribution @entity(immutable: true) { + # Identity + id: ID! + chainId: BigInt! + + # Relationships + token: Token! + pool: Pool! + + # Participants and fee configuration + caller: Bytes! + sushiRecipient: Bytes! + creatorRecipient: Bytes! + sushiFeeBps: Int! + + # Distributed amounts + amount0Collected: BigInt! + amount1Collected: BigInt! + amount0ToSushi: BigInt! + amount1ToSushi: BigInt! + amount0ToCreator: BigInt! + amount1ToCreator: BigInt! + + # Event metadata + transactionHash: Bytes! + logIndex: BigInt! + blockNumber: BigInt! + blockHash: Bytes! + timestamp: BigInt! +} + +type ReserveWithdrawal @entity(immutable: true) { + # Identity + id: ID! + chainId: BigInt! + + # Relationships + token: Token! + + # Withdrawal + recipient: Bytes! + amount: BigInt! + + # Event metadata + transactionHash: Bytes! + logIndex: BigInt! + blockNumber: BigInt! + blockHash: Bytes! + timestamp: BigInt! +} + +type LaunchFeeWithdrawal @entity(immutable: true) { + # Identity + id: ID! + chainId: BigInt! + + # Relationships + launchpad: Launchpad! + + # Withdrawal + recipient: Bytes! + amount: BigInt! + + # Event metadata + transactionHash: Bytes! + logIndex: BigInt! + blockNumber: BigInt! + blockHash: Bytes! + timestamp: BigInt! +} diff --git a/subgraphs/launchpad/src/mappings/helpers.ts b/subgraphs/launchpad/src/mappings/helpers.ts new file mode 100644 index 00000000..406a687c --- /dev/null +++ b/subgraphs/launchpad/src/mappings/helpers.ts @@ -0,0 +1,106 @@ +import { + Address, + BigInt, + Bytes, + dataSource, + ethereum, +} from "@graphprotocol/graph-ts"; +import { SushiLaunchpad as SushiLaunchpadContract } from "../../generated/SushiLaunchpad/SushiLaunchpad"; +import { Launchpad, Pool, Token } from "../../generated/schema"; + +export class DeploymentContext { + chainId: BigInt; + + constructor(chainId: BigInt) { + this.chainId = chainId; + } +} + +export function deploymentContext(): DeploymentContext { + const context = dataSource.context(); + assert( + context.get("chainId") != null, + "Launchpad data source context is incomplete" + ); + + return new DeploymentContext(context.getBigInt("chainId")); +} + +export function addressId(chainId: BigInt, address: Bytes): string { + return chainId.toString().concat(":").concat(address.toHexString()); +} + +export function canonicalHex(value: Bytes): string { + return value.toHexString().toLowerCase(); +} + +export function creatorId(launchpad: Launchpad, address: Bytes): string { + return launchpad.id.concat(":").concat(address.toHexString()); +} + +export function positionId( + chainId: BigInt, + positionManager: Bytes, + onchainPositionId: BigInt +): string { + return addressId(chainId, positionManager) + .concat(":") + .concat(onchainPositionId.toString()); +} + +export function eventId(chainId: BigInt, event: ethereum.Event): string { + return chainId + .toString() + .concat(":") + .concat(event.transaction.hash.toHexString()) + .concat(":") + .concat(event.logIndex.toString()); +} + +export function getOrCreateLaunchpad( + context: DeploymentContext, + factory: Address +): Launchpad { + const id = addressId(context.chainId, factory); + let launchpad = Launchpad.load(id); + if (launchpad == null) { + // Contract calls establish the first block-visible state. Mutable values + // are subsequently owned exclusively by their dedicated update handlers. + const contract = SushiLaunchpadContract.bind(factory); + launchpad = new Launchpad(id); + launchpad.chainId = context.chainId; + const positionManager = contract.positionManager(); + const protocolRecipient = contract.protocolRecipient(); + launchpad.address = factory; + launchpad.addressHex = canonicalHex(factory); + launchpad.positionManager = positionManager; + launchpad.positionManagerHex = canonicalHex(positionManager); + launchpad.protocolRecipient = protocolRecipient; + launchpad.protocolRecipientHex = canonicalHex(protocolRecipient); + launchpad.launchFee = contract.launchFee(); + launchpad.defaultSushiFeeBps = contract.defaultSushiFeeBps(); + launchpad.protocolReserveBps = contract.protocolReserveBps(); + launchpad.tokenCount = 0; + launchpad.creatorCount = 0; + launchpad.positionCount = 0; + launchpad.save(); + } + return changetype(launchpad); +} + +export function requireToken( + context: DeploymentContext, + address: Bytes +): Token { + const id = addressId(context.chainId, address); + const token = Token.load(id); + assert(token != null, "Launchpad event references unknown token " + id); + return changetype(token); +} + +export function requirePool(context: DeploymentContext, address: Bytes): Pool { + const id = addressId(context.chainId, address); + const pool = Pool.load(id); + assert(pool != null, "Launchpad event references unknown pool " + id); + return changetype(pool); +} diff --git a/subgraphs/launchpad/src/mappings/launchpad.ts b/subgraphs/launchpad/src/mappings/launchpad.ts new file mode 100644 index 00000000..b9e0f8ac --- /dev/null +++ b/subgraphs/launchpad/src/mappings/launchpad.ts @@ -0,0 +1,95 @@ +import { + DefaultSushiFeeBpsUpdated as DefaultSushiFeeBpsUpdatedEvent, + LaunchFeesWithdrawn as LaunchFeesWithdrawnEvent, + LaunchFeeUpdated as LaunchFeeUpdatedEvent, + ProtocolRecipientUpdated as ProtocolRecipientUpdatedEvent, + ProtocolReserveBpsUpdated as ProtocolReserveBpsUpdatedEvent, +} from "../../generated/SushiLaunchpad/SushiLaunchpad"; +import { LaunchFeeWithdrawal } from "../../generated/schema"; +import { + canonicalHex, + deploymentContext, + eventId, + getOrCreateLaunchpad, +} from "./helpers"; + +const BPS_DENOMINATOR = 10_000; +const MAX_PROTOCOL_RESERVE_BPS = 1_000; + +export function handleDefaultSushiFeeBpsUpdated( + event: DefaultSushiFeeBpsUpdatedEvent +): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + assert( + event.params.newBps <= BPS_DENOMINATOR, + "Invalid default Sushi fee update for " + launchpad.id + ); + + launchpad.defaultSushiFeeBps = event.params.newBps; + launchpad.save(); +} + +export function handleProtocolReserveBpsUpdated( + event: ProtocolReserveBpsUpdatedEvent +): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + assert( + event.params.newBps <= MAX_PROTOCOL_RESERVE_BPS, + "Invalid protocol reserve update for " + launchpad.id + ); + + launchpad.protocolReserveBps = event.params.newBps; + launchpad.save(); +} + +export function handleProtocolRecipientUpdated( + event: ProtocolRecipientUpdatedEvent +): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + launchpad.protocolRecipient = event.params.newRecipient; + launchpad.protocolRecipientHex = canonicalHex(event.params.newRecipient); + launchpad.save(); +} + +export function handleLaunchFeeUpdated(event: LaunchFeeUpdatedEvent): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + launchpad.launchFee = event.params.newFee; + launchpad.save(); +} + +export function handleLaunchFeesWithdrawn( + event: LaunchFeesWithdrawnEvent +): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + const id = eventId(context.chainId, event); + assert( + LaunchFeeWithdrawal.load(id) == null, + "Duplicate launch fee withdrawal " + id + ); + + const withdrawal = new LaunchFeeWithdrawal(id); + withdrawal.chainId = context.chainId; + withdrawal.launchpad = launchpad.id; + withdrawal.recipient = event.params.recipient; + withdrawal.amount = event.params.amount; + withdrawal.transactionHash = event.transaction.hash; + withdrawal.logIndex = event.logIndex; + withdrawal.blockNumber = event.block.number; + withdrawal.blockHash = event.block.hash; + withdrawal.timestamp = event.block.timestamp; + withdrawal.save(); +} + +export { + handleFeesDistributed, + handleProtocolReserveWithdrawn, + handleSushiFeeBpsUpdated, + handleTokenLaunched, +} from "./token"; + +export { handlePositionCreated } from "./pool"; diff --git a/subgraphs/launchpad/src/mappings/pool.ts b/subgraphs/launchpad/src/mappings/pool.ts new file mode 100644 index 00000000..a979f85f --- /dev/null +++ b/subgraphs/launchpad/src/mappings/pool.ts @@ -0,0 +1,105 @@ +import { BigInt } from "@graphprotocol/graph-ts"; +import { PositionCreated as PositionCreatedEvent } from "../../generated/SushiLaunchpad/SushiLaunchpad"; +import { SushiV3Pool } from "../../generated/SushiLaunchpad/SushiV3Pool"; +import { LaunchPosition, Pool, Token } from "../../generated/schema"; +import { + addressId, + canonicalHex, + deploymentContext, + getOrCreateLaunchpad, + positionId, +} from "./helpers"; + +export function handlePositionCreated(event: PositionCreatedEvent): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + const idForToken = addressId(context.chainId, event.params.token); + assert( + Token.load(idForToken) == null, + "Position emitted after token launch " + idForToken + ); + + const idForPool = addressId(context.chainId, event.params.pool); + let pool = Pool.load(idForPool); + if (pool == null) { + const contract = SushiV3Pool.bind(event.params.pool); + const token0 = contract.token0(); + const token1 = contract.token1(); + const tokenIs0 = token0.equals(event.params.token); + const tokenIs1 = token1.equals(event.params.token); + assert( + tokenIs0 != tokenIs1, + "Launch position token must be exactly one side of pool " + idForPool + ); + pool = new Pool(idForPool); + pool.chainId = context.chainId; + pool.launchpad = launchpad.id; + pool.address = event.params.pool; + pool.addressHex = canonicalHex(event.params.pool); + pool.token0 = token0; + pool.token0Hex = canonicalHex(token0); + pool.token1 = token1; + pool.token1Hex = canonicalHex(token1); + pool.fee = contract.fee(); + pool.tickSpacing = contract.tickSpacing(); + pool.positionManager = launchpad.positionManager; + pool.positionManagerHex = canonicalHex(launchpad.positionManager); + pool.positionCount = 0; + pool.creationTransactionHash = event.transaction.hash; + pool.creationTransactionHashHex = canonicalHex(event.transaction.hash); + pool.creationBlockNumber = event.block.number; + pool.creationBlockHash = event.block.hash; + pool.creationBlockHashHex = canonicalHex(event.block.hash); + pool.createdAt = event.block.timestamp; + } else { + const tokenIs0 = pool.token0.equals(event.params.token); + const tokenIs1 = pool.token1.equals(event.params.token); + assert( + pool.launchpad == launchpad.id && + pool.address.equals(event.params.pool) && + pool.positionManager.equals(launchpad.positionManager) && + tokenIs0 != tokenIs1, + "Inconsistent ordered position events for pool " + idForPool + ); + } + + const idForPosition = positionId( + context.chainId, + launchpad.positionManager, + event.params.positionId + ); + assert( + LaunchPosition.load(idForPosition) == null, + "Duplicate launch position " + idForPosition + ); + + const zero = BigInt.zero(); + const tokenIs0 = pool.token0.equals(event.params.token); + const position = new LaunchPosition(idForPosition); + position.chainId = context.chainId; + position.pool = pool.id; + position.positionManager = launchpad.positionManager; + position.positionManagerHex = canonicalHex(launchpad.positionManager); + position.positionId = event.params.positionId; + position.index = pool.positionCount; + position.tickLower = event.params.tickLower; + position.tickUpper = event.params.tickUpper; + position.amount0Desired = tokenIs0 ? event.params.tokenDesired : zero; + position.amount1Desired = tokenIs0 ? zero : event.params.tokenDesired; + position.amount0 = tokenIs0 ? event.params.tokenUsed : zero; + position.amount1 = tokenIs0 ? zero : event.params.tokenUsed; + position.liquidity = event.params.liquidity; + position.creationTransactionHash = event.transaction.hash; + position.creationTransactionHashHex = canonicalHex(event.transaction.hash); + position.creationLogIndex = event.logIndex; + position.creationBlockNumber = event.block.number; + position.creationBlockHash = event.block.hash; + position.creationBlockHashHex = canonicalHex(event.block.hash); + position.createdAt = event.block.timestamp; + position.save(); + + pool.positionCount += 1; + pool.save(); + launchpad.positionCount += 1; + launchpad.save(); +} diff --git a/subgraphs/launchpad/src/mappings/token.ts b/subgraphs/launchpad/src/mappings/token.ts new file mode 100644 index 00000000..b0df50cf --- /dev/null +++ b/subgraphs/launchpad/src/mappings/token.ts @@ -0,0 +1,226 @@ +import { BigInt } from "@graphprotocol/graph-ts"; +import { + FeesDistributed as FeesDistributedEvent, + ProtocolReserveWithdrawn as ProtocolReserveWithdrawnEvent, + SushiFeeBpsUpdated as SushiFeeBpsUpdatedEvent, + TokenLaunched as TokenLaunchedEvent, +} from "../../generated/SushiLaunchpad/SushiLaunchpad"; +import { + Creator, + FeeDistribution, + ReserveWithdrawal, + Token, +} from "../../generated/schema"; +import { + addressId, + canonicalHex, + creatorId, + deploymentContext, + eventId, + getOrCreateLaunchpad, + requirePool, + requireToken, +} from "./helpers"; + +const BPS_DENOMINATOR = 10_000; + +export function handleTokenLaunched(event: TokenLaunchedEvent): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + const idForToken = addressId(context.chainId, event.params.token); + assert( + Token.load(idForToken) == null, + "Duplicate token launch " + idForToken + ); + + const pool = requirePool(context, event.params.pool); + assert( + pool.launchpad == launchpad.id && + ((pool.token0.equals(event.params.token) && + pool.token1.equals(event.params.quoteToken)) || + (pool.token1.equals(event.params.token) && + pool.token0.equals(event.params.quoteToken))) && + event.params.positionCount.equals(BigInt.fromI32(pool.positionCount)), + "Token launch does not reconcile with pool " + pool.id + ); + + const idForCreator = creatorId(launchpad, event.params.creator); + let creator = Creator.load(idForCreator); + if (creator == null) { + creator = new Creator(idForCreator); + creator.chainId = context.chainId; + creator.launchpad = launchpad.id; + creator.address = event.params.creator; + creator.addressHex = canonicalHex(event.params.creator); + creator.tokenCount = 0; + launchpad.creatorCount += 1; + } + creator.tokenCount += 1; + creator.save(); + + const zero = BigInt.zero(); + const token = new Token(idForToken); + token.chainId = context.chainId; + token.launchpad = launchpad.id; + token.address = event.params.token; + token.addressHex = canonicalHex(event.params.token); + token.creator = creator.id; + token.pool = pool.id; + token.quoteToken = event.params.quoteToken; + token.quoteTokenHex = canonicalHex(event.params.quoteToken); + token.name = event.params.name; + token.symbol = event.params.symbol; + token.decimals = event.params.decimals; + token.totalSupply = event.params.totalSupply; + token.sushiFeeBps = event.params.initialSushiFeeBps; + token.reserveBps = event.params.reserveBps; + token.reserveAmount = event.params.reserveAmount; + token.reserveUnlockAt = event.params.reserveUnlockAt; + token.reserveWithdrawn = false; + token.totalAmount0Collected = zero; + token.totalAmount1Collected = zero; + token.totalAmount0ToSushi = zero; + token.totalAmount1ToSushi = zero; + token.totalAmount0ToCreator = zero; + token.totalAmount1ToCreator = zero; + token.creationTransactionHash = event.transaction.hash; + token.creationTransactionHashHex = canonicalHex(event.transaction.hash); + token.creationLogIndex = event.logIndex; + token.creationBlockNumber = event.block.number; + token.creationBlockHash = event.block.hash; + token.creationBlockHashHex = canonicalHex(event.block.hash); + token.createdAt = event.block.timestamp; + token.save(); + + launchpad.tokenCount += 1; + launchpad.save(); +} + +export function handleSushiFeeBpsUpdated(event: SushiFeeBpsUpdatedEvent): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + const token = requireToken(context, event.params.token); + assert( + token.launchpad == launchpad.id && + token.sushiFeeBps == event.params.previousSushiFeeBps && + event.params.newSushiFeeBps <= BPS_DENOMINATOR, + "Sushi fee update does not reconcile for " + token.id + ); + + token.sushiFeeBps = event.params.newSushiFeeBps; + token.save(); +} + +export function handleFeesDistributed(event: FeesDistributedEvent): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + const token = requireToken(context, event.params.token); + const pool = requirePool(context, event.params.pool); + const creator = Creator.load(token.creator); + assert(creator != null, "Token references unknown creator " + token.creator); + const tokenCreator = changetype(creator); + assert( + token.launchpad == launchpad.id && + token.pool == pool.id && + tokenCreator.address.equals(event.params.creator) && + token.sushiFeeBps == event.params.sushiFeeBps && + event.params.quoteCollected.equals( + event.params.quoteToSushi.plus(event.params.quoteToCreator) + ) && + event.params.tokenCollected.equals( + event.params.tokenToSushi.plus(event.params.tokenToCreator) + ), + "Fee distribution does not reconcile for " + token.id + ); + + const id = eventId(context.chainId, event); + assert(FeeDistribution.load(id) == null, "Duplicate fee distribution " + id); + const tokenIs0 = pool.token0.equals(event.params.token); + const distribution = new FeeDistribution(id); + distribution.chainId = context.chainId; + distribution.token = token.id; + distribution.pool = pool.id; + distribution.caller = event.params.caller; + distribution.sushiRecipient = event.params.protocolRecipient; + distribution.creatorRecipient = event.params.creator; + distribution.sushiFeeBps = event.params.sushiFeeBps; + distribution.amount0Collected = tokenIs0 + ? event.params.tokenCollected + : event.params.quoteCollected; + distribution.amount1Collected = tokenIs0 + ? event.params.quoteCollected + : event.params.tokenCollected; + distribution.amount0ToSushi = tokenIs0 + ? event.params.tokenToSushi + : event.params.quoteToSushi; + distribution.amount1ToSushi = tokenIs0 + ? event.params.quoteToSushi + : event.params.tokenToSushi; + distribution.amount0ToCreator = tokenIs0 + ? event.params.tokenToCreator + : event.params.quoteToCreator; + distribution.amount1ToCreator = tokenIs0 + ? event.params.quoteToCreator + : event.params.tokenToCreator; + distribution.transactionHash = event.transaction.hash; + distribution.logIndex = event.logIndex; + distribution.blockNumber = event.block.number; + distribution.blockHash = event.block.hash; + distribution.timestamp = event.block.timestamp; + distribution.save(); + + token.totalAmount0Collected = token.totalAmount0Collected.plus( + distribution.amount0Collected + ); + token.totalAmount1Collected = token.totalAmount1Collected.plus( + distribution.amount1Collected + ); + token.totalAmount0ToSushi = token.totalAmount0ToSushi.plus( + distribution.amount0ToSushi + ); + token.totalAmount1ToSushi = token.totalAmount1ToSushi.plus( + distribution.amount1ToSushi + ); + token.totalAmount0ToCreator = token.totalAmount0ToCreator.plus( + distribution.amount0ToCreator + ); + token.totalAmount1ToCreator = token.totalAmount1ToCreator.plus( + distribution.amount1ToCreator + ); + token.save(); +} + +export function handleProtocolReserveWithdrawn( + event: ProtocolReserveWithdrawnEvent +): void { + const context = deploymentContext(); + const launchpad = getOrCreateLaunchpad(context, event.address); + const token = requireToken(context, event.params.token); + assert( + token.launchpad == launchpad.id && + !token.reserveWithdrawn && + event.params.amount.equals(token.reserveAmount), + "Reserve withdrawal does not reconcile for " + token.id + ); + + const id = eventId(context.chainId, event); + assert( + ReserveWithdrawal.load(id) == null, + "Duplicate reserve withdrawal " + id + ); + const withdrawal = new ReserveWithdrawal(id); + withdrawal.chainId = context.chainId; + withdrawal.token = token.id; + withdrawal.recipient = event.params.recipient; + withdrawal.amount = event.params.amount; + withdrawal.transactionHash = event.transaction.hash; + withdrawal.logIndex = event.logIndex; + withdrawal.blockNumber = event.block.number; + withdrawal.blockHash = event.block.hash; + withdrawal.timestamp = event.block.timestamp; + withdrawal.save(); + + token.reserveWithdrawn = true; + token.reserveWithdrawal = withdrawal.id; + token.save(); +} diff --git a/subgraphs/launchpad/template.yaml b/subgraphs/launchpad/template.yaml new file mode 100644 index 00000000..9c903512 --- /dev/null +++ b/subgraphs/launchpad/template.yaml @@ -0,0 +1,59 @@ +specVersion: 1.0.0 +description: Canonical Sushi Launchpad registry and management activity +repository: https://github.com/sushiswap/subgraphs +schema: + file: ./schema.graphql +dataSources: + {{#launchpad.deployments}} + - kind: ethereum/contract + name: {{ name }} + network: {{ network }} + context: + chainId: + type: BigInt + data: '{{ chainId }}' + source: + address: '{{ address }}' + startBlock: {{ startBlock }} + abi: SushiLaunchpad + mapping: + kind: ethereum/events + apiVersion: 0.0.9 + language: wasm/assemblyscript + file: ./src/mappings/launchpad.ts + entities: + - Launchpad + - Creator + - Token + - Pool + - LaunchPosition + - FeeDistribution + - ReserveWithdrawal + - LaunchFeeWithdrawal + abis: + - name: SushiLaunchpad + file: ./abis/SushiLaunchpad.json + - name: SushiV3Pool + file: ../v3/abis/pool.json + eventHandlers: + - event: TokenLaunched(indexed address,indexed address,indexed address,address,string,string,uint8,uint256,uint16,uint256,uint64,uint16,uint256) + handler: handleTokenLaunched + - event: PositionCreated(indexed address,indexed address,indexed uint256,int24,int24,uint256,uint256,uint128) + handler: handlePositionCreated + - event: SushiFeeBpsUpdated(indexed address,uint16,uint16) + handler: handleSushiFeeBpsUpdated + - event: DefaultSushiFeeBpsUpdated(uint16,uint16) + handler: handleDefaultSushiFeeBpsUpdated + - event: ProtocolReserveBpsUpdated(uint16,uint16) + handler: handleProtocolReserveBpsUpdated + - event: ProtocolRecipientUpdated(indexed address,indexed address) + handler: handleProtocolRecipientUpdated + - event: LaunchFeeUpdated(uint256,uint256) + handler: handleLaunchFeeUpdated + - event: LaunchFeesWithdrawn(indexed address,uint256) + handler: handleLaunchFeesWithdrawn + - event: FeesDistributed(indexed address,indexed address,indexed address,address,address,uint16,uint256,uint256,uint256,uint256,uint256,uint256) + handler: handleFeesDistributed + - event: ProtocolReserveWithdrawn(indexed address,indexed address,uint256) + handler: handleProtocolReserveWithdrawn + {{/launchpad.deployments}} diff --git a/subgraphs/launchpad/tests/.latest.json b/subgraphs/launchpad/tests/.latest.json new file mode 100644 index 00000000..788b76b8 --- /dev/null +++ b/subgraphs/launchpad/tests/.latest.json @@ -0,0 +1,4 @@ +{ + "version": "0.6.0", + "timestamp": 1784652490157 +} \ No newline at end of file diff --git a/subgraphs/launchpad/tests/Launchpad.test.ts b/subgraphs/launchpad/tests/Launchpad.test.ts new file mode 100644 index 00000000..5320e17e --- /dev/null +++ b/subgraphs/launchpad/tests/Launchpad.test.ts @@ -0,0 +1,488 @@ +import { BigInt } from "@graphprotocol/graph-ts"; +import { afterEach, assert, beforeEach, clearStore, test } from "matchstick-as"; +import { + handleDefaultSushiFeeBpsUpdated, + handleFeesDistributed, + handleLaunchFeesWithdrawn, + handleLaunchFeeUpdated, + handlePositionCreated, + handleProtocolRecipientUpdated, + handleProtocolReserveBpsUpdated, + handleProtocolReserveWithdrawn, + handleSushiFeeBpsUpdated, + handleTokenLaunched, +} from "../src/mappings/launchpad"; +import { + CALLER, + CHAIN_ID, + CREATOR, + CREATOR_TWO, + FACTORY, + OTHER_POOL, + POOL, + POOL_THREE, + POSITION_MANAGER, + PROTOCOL_RECIPIENT, + PROTOCOL_RECIPIENT_TWO, + QUOTE_TOKEN, + QUOTE_TOKEN_TWO, + TOKEN, + TOKEN_THREE, + TOKEN_TWO, + createDefaultSushiFeeBpsUpdated, + createFeesDistributed, + createLaunchFeesWithdrawn, + createLaunchFeeUpdated, + createPositionCreated, + createProtocolReserveBpsUpdated, + createProtocolRecipientUpdated, + createProtocolReserveWithdrawn, + createSushiFeeBpsUpdated, + createTokenLaunched, + mockDeploymentContext, + mockV3Pool, +} from "./mocks"; + +const LAUNCHPAD_ID = CHAIN_ID.toString() + ":" + FACTORY.toHexString(); +const TOKEN_ID = CHAIN_ID.toString() + ":" + TOKEN.toHexString(); +const POOL_ID = CHAIN_ID.toString() + ":" + POOL.toHexString(); +const CREATOR_ID = LAUNCHPAD_ID + ":" + CREATOR.toHexString(); +const POSITION_PREFIX = + CHAIN_ID.toString() + ":" + POSITION_MANAGER.toHexString() + ":"; + +beforeEach(() => { + mockDeploymentContext(); +}); + +afterEach(() => { + clearStore(); +}); + +test("builds Launchpad, Token, Pool, and canonical initial positions", () => { + handlePositionCreated(createPositionCreated(101, 10)); + handlePositionCreated(createPositionCreated(102, 11)); + + assert.entityCount("Token", 0); + assert.entityCount("Pool", 1); + assert.fieldEquals("Pool", POOL_ID, "positionCount", "2"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "positionCount", "2"); + + handleTokenLaunched(createTokenLaunched(2, 12)); + + assert.entityCount("Launchpad", 1); + assert.entityCount("Creator", 1); + assert.entityCount("Token", 1); + assert.entityCount("Pool", 1); + assert.entityCount("LaunchPosition", 2); + + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "chainId", CHAIN_ID.toString()); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "address", + FACTORY.toHexString() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "addressHex", + FACTORY.toHexString().toLowerCase() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "positionManager", + POSITION_MANAGER.toHexString() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "positionManagerHex", + POSITION_MANAGER.toHexString().toLowerCase() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "protocolRecipient", + PROTOCOL_RECIPIENT.toHexString() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "protocolRecipientHex", + PROTOCOL_RECIPIENT.toHexString().toLowerCase() + ); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "launchFee", "500000000000000"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "defaultSushiFeeBps", "7000"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "protocolReserveBps", "300"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "tokenCount", "1"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "creatorCount", "1"); + + assert.fieldEquals("Creator", CREATOR_ID, "address", CREATOR.toHexString()); + assert.fieldEquals( + "Creator", + CREATOR_ID, + "addressHex", + CREATOR.toHexString().toLowerCase() + ); + assert.fieldEquals("Creator", CREATOR_ID, "tokenCount", "1"); + + assert.fieldEquals("Token", TOKEN_ID, "address", TOKEN.toHexString()); + assert.fieldEquals( + "Token", + TOKEN_ID, + "addressHex", + TOKEN.toHexString().toLowerCase() + ); + assert.fieldEquals("Token", TOKEN_ID, "launchpad", LAUNCHPAD_ID); + assert.fieldEquals("Token", TOKEN_ID, "creator", CREATOR_ID); + assert.fieldEquals("Token", TOKEN_ID, "pool", POOL_ID); + assert.fieldEquals( + "Token", + TOKEN_ID, + "quoteToken", + QUOTE_TOKEN.toHexString() + ); + assert.fieldEquals( + "Token", + TOKEN_ID, + "quoteTokenHex", + QUOTE_TOKEN.toHexString().toLowerCase() + ); + assert.fieldEquals("Token", TOKEN_ID, "name", "Sushi Test"); + assert.fieldEquals("Token", TOKEN_ID, "symbol", "SUSHIT"); + assert.fieldEquals("Token", TOKEN_ID, "decimals", "18"); + assert.fieldEquals("Token", TOKEN_ID, "sushiFeeBps", "7000"); + assert.fieldEquals("Token", TOKEN_ID, "reserveBps", "300"); + assert.fieldEquals("Token", TOKEN_ID, "reserveAmount", "30000000000000000"); + assert.fieldEquals("Token", TOKEN_ID, "reserveUnlockAt", "31536001"); + assert.fieldEquals("Token", TOKEN_ID, "reserveWithdrawn", "false"); + + assert.fieldEquals("Pool", POOL_ID, "token0", TOKEN.toHexString()); + assert.fieldEquals( + "Pool", + POOL_ID, + "token0Hex", + TOKEN.toHexString().toLowerCase() + ); + assert.fieldEquals("Pool", POOL_ID, "token1", QUOTE_TOKEN.toHexString()); + assert.fieldEquals( + "Pool", + POOL_ID, + "token1Hex", + QUOTE_TOKEN.toHexString().toLowerCase() + ); + assert.fieldEquals("Pool", POOL_ID, "fee", "10000"); + assert.fieldEquals("Pool", POOL_ID, "tickSpacing", "200"); + + const firstPositionId = POSITION_PREFIX + "101"; + assert.fieldEquals("LaunchPosition", firstPositionId, "pool", POOL_ID); + assert.fieldEquals( + "LaunchPosition", + firstPositionId, + "positionManagerHex", + POSITION_MANAGER.toHexString().toLowerCase() + ); + assert.fieldEquals("LaunchPosition", firstPositionId, "index", "0"); + assert.fieldEquals("LaunchPosition", firstPositionId, "tickLower", "-400"); + assert.fieldEquals("LaunchPosition", firstPositionId, "tickUpper", "-200"); + assert.fieldEquals( + "LaunchPosition", + firstPositionId, + "amount0Desired", + "1000" + ); + assert.fieldEquals("LaunchPosition", firstPositionId, "amount1Desired", "0"); + assert.fieldEquals("LaunchPosition", firstPositionId, "amount0", "999"); + assert.fieldEquals("LaunchPosition", firstPositionId, "amount1", "0"); +}); + +test("counts distinct creators and canonicalizes either token ordering", () => { + handlePositionCreated(createPositionCreated(101, 10)); + handleTokenLaunched(createTokenLaunched(1, 11)); + + mockV3Pool(OTHER_POOL, QUOTE_TOKEN, TOKEN_TWO); + handlePositionCreated(createPositionCreated(102, 20, OTHER_POOL, TOKEN_TWO)); + handleTokenLaunched( + createTokenLaunched(1, 21, TOKEN_TWO, OTHER_POOL, CREATOR) + ); + + mockV3Pool(POOL_THREE, TOKEN_THREE, QUOTE_TOKEN_TWO); + handlePositionCreated( + createPositionCreated(103, 30, POOL_THREE, TOKEN_THREE) + ); + handleTokenLaunched( + createTokenLaunched( + 1, + 31, + TOKEN_THREE, + POOL_THREE, + CREATOR_TWO, + QUOTE_TOKEN_TWO + ) + ); + + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "tokenCount", "3"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "creatorCount", "2"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "positionCount", "3"); + assert.fieldEquals("Creator", CREATOR_ID, "tokenCount", "2"); + + const secondPoolId = CHAIN_ID.toString() + ":" + OTHER_POOL.toHexString(); + const secondTokenId = CHAIN_ID.toString() + ":" + TOKEN_TWO.toHexString(); + const thirdTokenId = CHAIN_ID.toString() + ":" + TOKEN_THREE.toHexString(); + assert.fieldEquals( + "Token", + secondTokenId, + "quoteToken", + QUOTE_TOKEN.toHexString() + ); + assert.fieldEquals( + "Token", + thirdTokenId, + "quoteToken", + QUOTE_TOKEN_TWO.toHexString() + ); + assert.fieldEquals("Pool", secondPoolId, "token0", QUOTE_TOKEN.toHexString()); + assert.fieldEquals("Pool", secondPoolId, "token1", TOKEN_TWO.toHexString()); + assert.fieldEquals( + "LaunchPosition", + POSITION_PREFIX + "102", + "amount0Desired", + "0" + ); + assert.fieldEquals( + "LaunchPosition", + POSITION_PREFIX + "102", + "amount1Desired", + "1000" + ); + assert.fieldEquals("LaunchPosition", POSITION_PREFIX + "102", "amount0", "0"); + assert.fieldEquals( + "LaunchPosition", + POSITION_PREFIX + "102", + "amount1", + "999" + ); + + handleSushiFeeBpsUpdated( + createSushiFeeBpsUpdated(7_000, 6_500, 40, TOKEN_TWO) + ); + const distribution = createFeesDistributed( + 41, + TOKEN_TWO, + OTHER_POOL, + CREATOR + ); + handleFeesDistributed(distribution); + const distributionId = + CHAIN_ID.toString() + + ":" + + distribution.transaction.hash.toHexString() + + ":41"; + assert.fieldEquals( + "FeeDistribution", + distributionId, + "amount0Collected", + "100" + ); + assert.fieldEquals( + "FeeDistribution", + distributionId, + "amount1Collected", + "200" + ); +}); + +test("tracks current Launchpad defaults", () => { + handleDefaultSushiFeeBpsUpdated( + createDefaultSushiFeeBpsUpdated(7_000, 6_000, 10) + ); + handleProtocolReserveBpsUpdated( + createProtocolReserveBpsUpdated(300, 500, 11) + ); + + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "defaultSushiFeeBps", "6000"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "protocolReserveBps", "500"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "tokenCount", "0"); +}); + +test("keeps token launch snapshots scoped to Token", () => { + handlePositionCreated(createPositionCreated(101, 10)); + handleTokenLaunched( + createTokenLaunched(1, 11, TOKEN, POOL, CREATOR, QUOTE_TOKEN, 6_000, 500) + ); + + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "defaultSushiFeeBps", "7000"); + assert.fieldEquals("Launchpad", LAUNCHPAD_ID, "protocolReserveBps", "300"); + assert.fieldEquals("Token", TOKEN_ID, "sushiFeeBps", "6000"); + assert.fieldEquals("Token", TOKEN_ID, "reserveBps", "500"); +}); + +test("tracks Launchpad recipient, launch fee, and fee withdrawals", () => { + const initialFee = BigInt.fromString("500000000000000"); + const updatedFee = BigInt.fromString("1000000000000000"); + const withdrawn = BigInt.fromString("1500000000000000"); + + handleProtocolRecipientUpdated( + createProtocolRecipientUpdated( + PROTOCOL_RECIPIENT, + PROTOCOL_RECIPIENT_TWO, + 10 + ) + ); + handleLaunchFeeUpdated(createLaunchFeeUpdated(initialFee, updatedFee, 11)); + const withdrawal = createLaunchFeesWithdrawn( + PROTOCOL_RECIPIENT_TWO, + withdrawn, + 12 + ); + handleLaunchFeesWithdrawn(withdrawal); + + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "protocolRecipient", + PROTOCOL_RECIPIENT_TWO.toHexString() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "protocolRecipientHex", + PROTOCOL_RECIPIENT_TWO.toHexString().toLowerCase() + ); + assert.fieldEquals( + "Launchpad", + LAUNCHPAD_ID, + "launchFee", + updatedFee.toString() + ); + const withdrawalId = + CHAIN_ID.toString() + + ":" + + withdrawal.transaction.hash.toHexString() + + ":12"; + assert.fieldEquals( + "LaunchFeeWithdrawal", + withdrawalId, + "launchpad", + LAUNCHPAD_ID + ); + assert.fieldEquals( + "LaunchFeeWithdrawal", + withdrawalId, + "recipient", + PROTOCOL_RECIPIENT_TWO.toHexString() + ); + assert.fieldEquals( + "LaunchFeeWithdrawal", + withdrawalId, + "amount", + withdrawn.toString() + ); +}); + +test("tracks fee distributions and reserve withdrawals on Token", () => { + handlePositionCreated(createPositionCreated(101, 10)); + handleTokenLaunched(createTokenLaunched(1, 11)); + handleSushiFeeBpsUpdated(createSushiFeeBpsUpdated(7_000, 6_500, 12)); + + const distribution = createFeesDistributed(13); + handleFeesDistributed(distribution); + handleProtocolReserveWithdrawn(createProtocolReserveWithdrawn(14)); + + assert.fieldEquals("Token", TOKEN_ID, "sushiFeeBps", "6500"); + assert.fieldEquals("Token", TOKEN_ID, "totalAmount0Collected", "200"); + assert.fieldEquals("Token", TOKEN_ID, "totalAmount1Collected", "100"); + assert.fieldEquals("Token", TOKEN_ID, "totalAmount0ToSushi", "130"); + assert.fieldEquals("Token", TOKEN_ID, "totalAmount1ToSushi", "65"); + assert.fieldEquals("Token", TOKEN_ID, "totalAmount0ToCreator", "70"); + assert.fieldEquals("Token", TOKEN_ID, "totalAmount1ToCreator", "35"); + assert.fieldEquals("Token", TOKEN_ID, "reserveWithdrawn", "true"); + + const distributionId = + CHAIN_ID.toString() + + ":" + + distribution.transaction.hash.toHexString() + + ":13"; + assert.fieldEquals("FeeDistribution", distributionId, "token", TOKEN_ID); + assert.fieldEquals("FeeDistribution", distributionId, "pool", POOL_ID); + assert.fieldEquals( + "FeeDistribution", + distributionId, + "caller", + CALLER.toHexString() + ); + assert.fieldEquals( + "FeeDistribution", + distributionId, + "sushiRecipient", + PROTOCOL_RECIPIENT.toHexString() + ); + assert.fieldEquals( + "FeeDistribution", + distributionId, + "creatorRecipient", + CREATOR.toHexString() + ); + assert.fieldEquals( + "FeeDistribution", + distributionId, + "amount0Collected", + "200" + ); + assert.fieldEquals( + "FeeDistribution", + distributionId, + "amount1Collected", + "100" + ); + + const withdrawalId = + CHAIN_ID.toString() + + ":" + + distribution.transaction.hash.toHexString() + + ":14"; + assert.fieldEquals("Token", TOKEN_ID, "reserveWithdrawal", withdrawalId); + assert.fieldEquals("ReserveWithdrawal", withdrawalId, "token", TOKEN_ID); + assert.fieldEquals( + "ReserveWithdrawal", + withdrawalId, + "recipient", + PROTOCOL_RECIPIENT.toHexString() + ); + assert.fieldEquals( + "ReserveWithdrawal", + withdrawalId, + "amount", + "30000000000000000" + ); +}); + +test( + "fails deterministically when ordered position identities disagree", + () => { + handlePositionCreated(createPositionCreated(101, 10)); + handlePositionCreated(createPositionCreated(102, 11, POOL, TOKEN_TWO)); + }, + true +); + +test( + "fails deterministically when the launch count does not reconcile", + () => { + handlePositionCreated(createPositionCreated(101, 10)); + handleTokenLaunched(createTokenLaunched(2, 11)); + }, + true +); + +test( + "fails deterministically when the emitted quote token does not match the pool", + () => { + handlePositionCreated(createPositionCreated(101, 10)); + handleTokenLaunched( + createTokenLaunched(1, 11, TOKEN, POOL, CREATOR, QUOTE_TOKEN_TWO) + ); + }, + true +); diff --git a/subgraphs/launchpad/tests/mocks.ts b/subgraphs/launchpad/tests/mocks.ts new file mode 100644 index 00000000..60346d2f --- /dev/null +++ b/subgraphs/launchpad/tests/mocks.ts @@ -0,0 +1,460 @@ +import { + Address, + BigInt, + DataSourceContext, + ethereum, +} from "@graphprotocol/graph-ts"; +import { + createMockedFunction, + dataSourceMock, + newMockEvent, +} from "matchstick-as"; +import { + DefaultSushiFeeBpsUpdated, + FeesDistributed, + LaunchFeesWithdrawn, + LaunchFeeUpdated, + PositionCreated, + ProtocolRecipientUpdated, + ProtocolReserveBpsUpdated, + ProtocolReserveWithdrawn, + SushiFeeBpsUpdated, + TokenLaunched, +} from "../generated/SushiLaunchpad/SushiLaunchpad"; + +export const CHAIN_ID = BigInt.fromI32(4663); +export const FACTORY = Address.fromString( + "0x1000000000000000000000000000000000000001" +); +export const CREATOR = Address.fromString( + "0x2000000000000000000000000000000000000002" +); +export const CREATOR_TWO = Address.fromString( + "0xc00000000000000000000000000000000000000c" +); +export const CALLER = Address.fromString( + "0x3000000000000000000000000000000000000003" +); +export const PROTOCOL_RECIPIENT = Address.fromString( + "0x4000000000000000000000000000000000000004" +); +export const PROTOCOL_RECIPIENT_TWO = Address.fromString( + "0xe00000000000000000000000000000000000000e" +); +export const TOKEN = Address.fromString( + "0x5000000000000000000000000000000000000005" +); +export const TOKEN_TWO = Address.fromString( + "0xa00000000000000000000000000000000000000a" +); +export const TOKEN_THREE = Address.fromString( + "0xb00000000000000000000000000000000000000b" +); +export const POOL = Address.fromString( + "0x6000000000000000000000000000000000000006" +); +export const OTHER_POOL = Address.fromString( + "0x7000000000000000000000000000000000000007" +); +export const POOL_THREE = Address.fromString( + "0xd00000000000000000000000000000000000000d" +); +export const QUOTE_TOKEN = Address.fromString( + "0x8000000000000000000000000000000000000008" +); +export const QUOTE_TOKEN_TWO = Address.fromString( + "0xf00000000000000000000000000000000000000f" +); +export const POSITION_MANAGER = Address.fromString( + "0x9000000000000000000000000000000000000009" +); + +export function mockDeploymentContext(): void { + const context = new DataSourceContext(); + context.setBigInt("chainId", CHAIN_ID); + dataSourceMock.setAddressAndContext(FACTORY.toHexString(), context); + + createMockedFunction( + FACTORY, + "positionManager", + "positionManager():(address)" + ).returns([ethereum.Value.fromAddress(POSITION_MANAGER)]); + createMockedFunction( + FACTORY, + "defaultSushiFeeBps", + "defaultSushiFeeBps():(uint16)" + ).returns([ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(7_000))]); + createMockedFunction( + FACTORY, + "protocolReserveBps", + "protocolReserveBps():(uint16)" + ).returns([ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(300))]); + createMockedFunction( + FACTORY, + "protocolRecipient", + "protocolRecipient():(address)" + ).returns([ethereum.Value.fromAddress(PROTOCOL_RECIPIENT)]); + createMockedFunction(FACTORY, "launchFee", "launchFee():(uint256)").returns([ + ethereum.Value.fromUnsignedBigInt(BigInt.fromString("500000000000000")), + ]); + mockV3Pool(POOL, TOKEN, QUOTE_TOKEN); +} + +export function mockV3Pool( + pool: Address, + token0: Address, + token1: Address +): void { + createMockedFunction(pool, "token0", "token0():(address)").returns([ + ethereum.Value.fromAddress(token0), + ]); + createMockedFunction(pool, "token1", "token1():(address)").returns([ + ethereum.Value.fromAddress(token1), + ]); + createMockedFunction(pool, "fee", "fee():(uint24)").returns([ + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(10_000)), + ]); + createMockedFunction(pool, "tickSpacing", "tickSpacing():(int24)").returns([ + ethereum.Value.fromI32(200), + ]); +} + +function configureEvent(event: ethereum.Event, logIndex: i32): void { + event.address = FACTORY; + event.logIndex = BigInt.fromI32(logIndex); +} + +export function createPositionCreated( + onchainPositionId: i32, + logIndex: i32, + pool: Address = POOL, + token: Address = TOKEN +): PositionCreated { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("token", ethereum.Value.fromAddress(token)) + ); + event.parameters.push( + new ethereum.EventParam("pool", ethereum.Value.fromAddress(pool)) + ); + event.parameters.push( + new ethereum.EventParam( + "positionId", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(onchainPositionId)) + ) + ); + event.parameters.push( + new ethereum.EventParam("tickLower", ethereum.Value.fromI32(-400)) + ); + event.parameters.push( + new ethereum.EventParam("tickUpper", ethereum.Value.fromI32(-200)) + ); + event.parameters.push( + new ethereum.EventParam( + "tokenDesired", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(1_000)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "tokenUsed", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(999)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "liquidity", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(500)) + ) + ); + return event; +} + +export function createTokenLaunched( + positionCount: i32, + logIndex: i32, + token: Address = TOKEN, + pool: Address = POOL, + creator: Address = CREATOR, + quoteToken: Address = QUOTE_TOKEN, + initialSushiFeeBps: i32 = 7_000, + reserveBps: i32 = 300 +): TokenLaunched { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("creator", ethereum.Value.fromAddress(creator)) + ); + event.parameters.push( + new ethereum.EventParam("token", ethereum.Value.fromAddress(token)) + ); + event.parameters.push( + new ethereum.EventParam("pool", ethereum.Value.fromAddress(pool)) + ); + event.parameters.push( + new ethereum.EventParam( + "quoteToken", + ethereum.Value.fromAddress(quoteToken) + ) + ); + event.parameters.push( + new ethereum.EventParam("name", ethereum.Value.fromString("Sushi Test")) + ); + event.parameters.push( + new ethereum.EventParam("symbol", ethereum.Value.fromString("SUSHIT")) + ); + event.parameters.push( + new ethereum.EventParam("decimals", ethereum.Value.fromI32(18)) + ); + event.parameters.push( + new ethereum.EventParam( + "totalSupply", + ethereum.Value.fromUnsignedBigInt( + BigInt.fromString("1000000000000000000") + ) + ) + ); + event.parameters.push( + new ethereum.EventParam("reserveBps", ethereum.Value.fromI32(reserveBps)) + ); + event.parameters.push( + new ethereum.EventParam( + "reserveAmount", + ethereum.Value.fromUnsignedBigInt(BigInt.fromString("30000000000000000")) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "reserveUnlockAt", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(31_536_001)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "initialSushiFeeBps", + ethereum.Value.fromI32(initialSushiFeeBps) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "positionCount", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(positionCount)) + ) + ); + return event; +} + +export function createSushiFeeBpsUpdated( + previousBps: i32, + newBps: i32, + logIndex: i32, + token: Address = TOKEN +): SushiFeeBpsUpdated { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("token", ethereum.Value.fromAddress(token)) + ); + event.parameters.push( + new ethereum.EventParam( + "previousSushiFeeBps", + ethereum.Value.fromI32(previousBps) + ) + ); + event.parameters.push( + new ethereum.EventParam("newSushiFeeBps", ethereum.Value.fromI32(newBps)) + ); + return event; +} + +export function createDefaultSushiFeeBpsUpdated( + previousBps: i32, + newBps: i32, + logIndex: i32 +): DefaultSushiFeeBpsUpdated { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("previousBps", ethereum.Value.fromI32(previousBps)) + ); + event.parameters.push( + new ethereum.EventParam("newBps", ethereum.Value.fromI32(newBps)) + ); + return event; +} + +export function createProtocolReserveBpsUpdated( + previousBps: i32, + newBps: i32, + logIndex: i32 +): ProtocolReserveBpsUpdated { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("previousBps", ethereum.Value.fromI32(previousBps)) + ); + event.parameters.push( + new ethereum.EventParam("newBps", ethereum.Value.fromI32(newBps)) + ); + return event; +} + +export function createProtocolRecipientUpdated( + previousRecipient: Address, + newRecipient: Address, + logIndex: i32 +): ProtocolRecipientUpdated { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam( + "previousRecipient", + ethereum.Value.fromAddress(previousRecipient) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "newRecipient", + ethereum.Value.fromAddress(newRecipient) + ) + ); + return event; +} + +export function createLaunchFeeUpdated( + previousFee: BigInt, + newFee: BigInt, + logIndex: i32 +): LaunchFeeUpdated { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam( + "previousFee", + ethereum.Value.fromUnsignedBigInt(previousFee) + ) + ); + event.parameters.push( + new ethereum.EventParam("newFee", ethereum.Value.fromUnsignedBigInt(newFee)) + ); + return event; +} + +export function createLaunchFeesWithdrawn( + recipient: Address, + amount: BigInt, + logIndex: i32 +): LaunchFeesWithdrawn { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("recipient", ethereum.Value.fromAddress(recipient)) + ); + event.parameters.push( + new ethereum.EventParam("amount", ethereum.Value.fromUnsignedBigInt(amount)) + ); + return event; +} + +export function createFeesDistributed( + logIndex: i32, + token: Address = TOKEN, + pool: Address = POOL, + creator: Address = CREATOR +): FeesDistributed { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("caller", ethereum.Value.fromAddress(CALLER)) + ); + event.parameters.push( + new ethereum.EventParam("token", ethereum.Value.fromAddress(token)) + ); + event.parameters.push( + new ethereum.EventParam("pool", ethereum.Value.fromAddress(pool)) + ); + event.parameters.push( + new ethereum.EventParam("creator", ethereum.Value.fromAddress(creator)) + ); + event.parameters.push( + new ethereum.EventParam( + "protocolRecipient", + ethereum.Value.fromAddress(PROTOCOL_RECIPIENT) + ) + ); + event.parameters.push( + new ethereum.EventParam("sushiFeeBps", ethereum.Value.fromI32(6_500)) + ); + event.parameters.push( + new ethereum.EventParam( + "quoteCollected", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(100)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "tokenCollected", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(200)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "quoteToSushi", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(65)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "tokenToSushi", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(130)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "quoteToCreator", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(35)) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "tokenToCreator", + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(70)) + ) + ); + return event; +} + +export function createProtocolReserveWithdrawn( + logIndex: i32, + token: Address = TOKEN +): ProtocolReserveWithdrawn { + const event = changetype(newMockEvent()); + configureEvent(event, logIndex); + event.parameters = new Array(); + event.parameters.push( + new ethereum.EventParam("token", ethereum.Value.fromAddress(token)) + ); + event.parameters.push( + new ethereum.EventParam( + "recipient", + ethereum.Value.fromAddress(PROTOCOL_RECIPIENT) + ) + ); + event.parameters.push( + new ethereum.EventParam( + "amount", + ethereum.Value.fromUnsignedBigInt(BigInt.fromString("30000000000000000")) + ) + ); + return event; +} diff --git a/subgraphs/launchpad/tsconfig.json b/subgraphs/launchpad/tsconfig.json new file mode 100644 index 00000000..8d9f5d71 --- /dev/null +++ b/subgraphs/launchpad/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.json", + "include": ["src", "generated", "tests"], + "compilerOptions": { + "baseUrl": ".", + "lib": ["ES2016"], + "rootDirs": ["./src", "./generated", "./tests"] + } +}