diff --git a/package.json b/package.json index 7624c0d..14c66bd 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,12 @@ "typescript-eslint": "^8.21.0", "vitest": "^3.0.3" }, + "peerDependencies": { + "@safe-global/api-kit": "^2.5.7", + "@safe-global/protocol-kit": "^5.2.0", + "@safe-global/types-kit": "^1.0.4", + "ethers": "^6.6.2" + }, "scripts": { "build": "pnpm types:json && pnpm codegen:api && rollup -c", "codegen:api": "npx orval --input https://api.hypercerts.org/swagger.json --output ./src/__generated__/api.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 777fba8..0b305fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,15 @@ importers: '@openzeppelin/merkle-tree': specifier: ^1.0.7 version: 1.0.7 + '@safe-global/api-kit': + specifier: ^2.5.7 + version: 2.5.11(typescript@5.4.5)(zod@3.24.1) + '@safe-global/protocol-kit': + specifier: ^5.2.0 + version: 5.2.4(typescript@5.4.5)(zod@3.24.1) + '@safe-global/types-kit': + specifier: ^1.0.4 + version: 1.0.4(typescript@5.4.5)(zod@3.24.1) '@swc/core': specifier: ^1.10.9 version: 1.10.9 @@ -29,6 +38,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.4.7 + ethers: + specifier: ^6.6.2 + version: 6.13.5 rollup-plugin-swc3: specifier: ^0.11.2 version: 0.11.2(@swc/core@1.10.9)(rollup@3.29.5) @@ -147,6 +159,9 @@ importers: packages: + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.0': resolution: {integrity: sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg==} @@ -748,6 +763,9 @@ packages: resolution: {integrity: sha512-tghyZKLHZjcdlDqCA3gNZmLeR0XvOE9U1qoQO9ohyAZT6Pya+H9vkBPcsyXytmYLNgVoin7CKCmweo/R43V+tQ==} engines: {node: '>=12.0.0'} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.7.0': resolution: {integrity: sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==} engines: {node: ^14.21.3 || >=16} @@ -755,6 +773,10 @@ packages: '@noble/hashes@1.2.0': resolution: {integrity: sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.6.0': resolution: {integrity: sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==} engines: {node: ^14.21.3 || >=16} @@ -898,6 +920,9 @@ packages: '@orval/zod@6.31.0': resolution: {integrity: sha512-v6wqGZf4s3tpWrnmMHlEBfhTLeebu5W3HmhP8vQ5BPkm8AB2asiZqzK3Ne9Y19Rvyx6X4FGnhnalKYkz+XxJ8Q==} + '@peculiar/asn1-schema@2.3.15': + resolution: {integrity: sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1033,6 +1058,21 @@ packages: cpu: [x64] os: [win32] + '@safe-global/api-kit@2.5.11': + resolution: {integrity: sha512-gNrbGI/vHbOplPrytTEe5+CmwOowkEjDoTqGxz6q/rQSEJ7d7z8YzVy8Zdia7ICld1nIymQmkBdXkLr2XrDwfQ==} + + '@safe-global/protocol-kit@5.2.4': + resolution: {integrity: sha512-HqEIoclgeit1xsNyZfnscUA3q3uwr0VwoDAnLpVpOTY3y/oh8AEwsFFYs5UGZ05zfQb5t2yfvDSZt0ye+8y86g==} + + '@safe-global/safe-deployments@1.37.30': + resolution: {integrity: sha512-fARm/2VkT4Om/EoaVG4G/TvxaXnVfJZQrsXi/3eDcIB0NwkjgTHoku7FfdY4Gl3EINCaUHnWT9t7CNMPJu/I5w==} + + '@safe-global/safe-modules-deployments@2.2.7': + resolution: {integrity: sha512-xlnAW7d0394EwlRgWJ+nuQNQmGkL0qBE54pN+1IBbUEFvWW8q0SbhDsTmlGgeDM+9F8q2KM06Ip1JMmddppA/Q==} + + '@safe-global/types-kit@1.0.4': + resolution: {integrity: sha512-PTVgu+tNrC0km7J/vSZBDGyv0yAEc9IWgh6oWyBPsCysxuLUqyVA53K1qrzzGfJL2mzpC7Bj4bx+Bt6iP9m/yQ==} + '@scure/base@1.1.7': resolution: {integrity: sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g==} @@ -1300,6 +1340,9 @@ packages: '@types/node@20.17.14': resolution: {integrity: sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/pbkdf2@3.1.2': resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} @@ -1451,6 +1494,9 @@ packages: resolution: {integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==} engines: {node: '>=0.3.0'} + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1552,6 +1598,10 @@ packages: resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} + asn1js@3.0.5: + resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} + engines: {node: '>=12.0.0'} + assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} @@ -2111,6 +2161,10 @@ packages: ethereumjs-util@6.2.1: resolution: {integrity: sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw==} + ethers@6.13.5: + resolution: {integrity: sha512-+knKNieu5EKRThQJWwqaJ10a6HE9sSehGeqWN65//wE7j47ZpFhKAnHB/JJFibwwg61I/koxaPsXbXpD/skNOQ==} + engines: {node: '>=14.0.0'} + ethjs-util@0.1.6: resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==} engines: {node: '>=6.5.0', npm: '>=3'} @@ -3259,6 +3313,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.3: + resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} + engines: {node: '>=6.0.0'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3735,8 +3796,11 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.6.3: - resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tsort@0.0.1: resolution: {integrity: sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw==} @@ -3985,6 +4049,18 @@ packages: utf-8-validate: optional: true + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + 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 + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -4052,6 +4128,8 @@ packages: snapshots: + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.0': {} '@ampproject/remapping@2.3.0': @@ -4652,12 +4730,18 @@ snapshots: tweetnacl: 1.0.3 tweetnacl-util: 0.15.1 + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.7.0': dependencies: '@noble/hashes': 1.6.0 '@noble/hashes@1.2.0': {} + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.6.0': {} '@noble/hashes@1.6.1': {} @@ -4856,6 +4940,13 @@ snapshots: - openapi-types - supports-color + '@peculiar/asn1-schema@2.3.15': + dependencies: + asn1js: 3.0.5 + pvtsutils: 1.3.6 + tslib: 2.8.1 + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -4951,6 +5042,49 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.31.0': optional: true + '@safe-global/api-kit@2.5.11(typescript@5.4.5)(zod@3.24.1)': + dependencies: + '@safe-global/protocol-kit': 5.2.4(typescript@5.4.5)(zod@3.24.1) + '@safe-global/types-kit': 1.0.4(typescript@5.4.5)(zod@3.24.1) + node-fetch: 2.7.0 + viem: 2.22.11(typescript@5.4.5)(zod@3.24.1) + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@safe-global/protocol-kit@5.2.4(typescript@5.4.5)(zod@3.24.1)': + dependencies: + '@safe-global/safe-deployments': 1.37.30 + '@safe-global/safe-modules-deployments': 2.2.7 + '@safe-global/types-kit': 1.0.4(typescript@5.4.5)(zod@3.24.1) + abitype: 1.0.7(typescript@5.4.5)(zod@3.24.1) + semver: 7.6.3 + viem: 2.22.11(typescript@5.4.5)(zod@3.24.1) + optionalDependencies: + '@noble/curves': 1.7.0 + '@peculiar/asn1-schema': 2.3.15 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + + '@safe-global/safe-deployments@1.37.30': + dependencies: + semver: 7.6.3 + + '@safe-global/safe-modules-deployments@2.2.7': {} + + '@safe-global/types-kit@1.0.4(typescript@5.4.5)(zod@3.24.1)': + dependencies: + abitype: 1.0.7(typescript@5.4.5)(zod@3.24.1) + transitivePeerDependencies: + - typescript + - zod + '@scure/base@1.1.7': {} '@scure/base@1.2.4': {} @@ -5069,7 +5203,7 @@ snapshots: fast-memoize: 2.5.2 immer: 9.0.21 lodash: 4.17.21 - tslib: 2.6.3 + tslib: 2.8.1 urijs: 1.19.11 '@stoplight/json@3.21.6': @@ -5107,7 +5241,7 @@ snapshots: nimma: 0.2.2 pony-cause: 1.1.1 simple-eval: 1.0.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -5116,7 +5250,7 @@ snapshots: '@stoplight/json': 3.21.6 '@stoplight/spectral-core': 1.18.3 '@types/json-schema': 7.0.15 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -5132,7 +5266,7 @@ snapshots: ajv-errors: 3.0.0(ajv@8.17.1) ajv-formats: 2.1.1(ajv@8.17.1) lodash: 4.17.21 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -5141,7 +5275,7 @@ snapshots: '@stoplight/json': 3.21.6 '@stoplight/types': 14.1.1 '@stoplight/yaml': 4.3.0 - tslib: 2.6.3 + tslib: 2.8.1 '@stoplight/spectral-ref-resolver@1.0.4': dependencies: @@ -5149,7 +5283,7 @@ snapshots: '@stoplight/json-ref-resolver': 3.1.6 '@stoplight/spectral-runtime': 1.1.2 dependency-graph: 0.11.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -5169,7 +5303,7 @@ snapshots: json-schema-traverse: 1.0.0 leven: 3.1.0 lodash: 4.17.21 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -5181,7 +5315,7 @@ snapshots: abort-controller: 3.0.0 lodash: 4.17.21 node-fetch: 2.7.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -5212,7 +5346,7 @@ snapshots: '@stoplight/ordered-object-literal': 1.0.5 '@stoplight/types': 14.1.1 '@stoplight/yaml-ast-parser': 0.0.50 - tslib: 2.6.3 + tslib: 2.8.1 '@swc/core-darwin-arm64@1.10.9': optional: true @@ -5321,6 +5455,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/pbkdf2@3.1.2': dependencies: '@types/node': 20.17.14 @@ -5516,6 +5654,8 @@ snapshots: adm-zip@0.4.16: {} + aes-js@4.0.0-beta.5: {} + agent-base@6.0.2: dependencies: debug: 4.4.0 @@ -5618,6 +5758,13 @@ snapshots: is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 + asn1js@3.0.5: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.3 + tslib: 2.8.1 + optional: true + assertion-error@1.1.0: {} assertion-error@2.0.1: {} @@ -6353,6 +6500,19 @@ snapshots: ethjs-util: 0.1.6 rlp: 2.2.7 + ethers@6.13.5: + dependencies: + '@adraffy/ens-normalize': 1.10.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.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ethjs-util@0.1.6: dependencies: is-hex-prefixed: 1.0.0 @@ -7576,6 +7736,14 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + optional: true + + pvutils@1.1.3: + optional: true + queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -8104,7 +8272,9 @@ snapshots: tslib@1.14.1: {} - tslib@2.6.3: {} + tslib@2.7.0: {} + + tslib@2.8.1: {} tsort@0.0.1: {} @@ -8356,6 +8526,8 @@ snapshots: ws@7.5.10: {} + ws@8.17.1: {} + ws@8.18.0: {} y18n@5.0.8: {} diff --git a/src/client.ts b/src/client.ts index 0951f7b..df4d36a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -29,6 +29,8 @@ import { getClaimStoredDataFromTxHash } from "./utils"; import { isClaimOnChain } from "./utils/chains"; import { HypercertStorage } from "./types/storage"; import { fetchFromHttpsOrIpfs } from "./utils/fetchers"; +import { onlyProvidedContractOverrides } from "./utils/overrides"; +import { SafeTransactions } from "./safe/SafeTransactions"; /** * The `HypercertClient` is a core class in the hypercerts SDK, providing a high-level interface to interact with the hypercerts system. @@ -150,18 +152,21 @@ export class HypercertClient implements HypercertClientInterface { * This function handles the minting process of a hypercert, including fetching and parsing the allowlist if provided, * validating and storing metadata, and submitting the minting request. * - * @param metaData - The metadata for the hypercert. - * @param totalUnits - The total units of the hypercert. - * @param transferRestriction - The transfer restrictions for the hypercert. - * @param allowList - The allowlist for the hypercert, either as a URI to a CSV file or an array of allowlist entries. - * @param overrides - Optional overrides for the transaction. + * If you provide a `safeAddress` in the `overrides` parameter, the minting will be performed as a Safe transaction. + * + * @param params - The parameters for minting a hypercert + * @param params.metaData - The metadata for the hypercert. + * @param params.totalUnits - The total units of the hypercert. + * @param params.transferRestriction - The transfer restrictions for the hypercert. + * @param params.allowList - The allowlist for the hypercert, either as a URI to a CSV file or an array of allowlist entries. + * @param params.overrides - Optional overrides for the transaction. * @returns A promise that resolves to the transaction hash of the minting request. * @throws Will throw a `ClientError` if any validation or request submission fails. */ mintHypercert = async ({ metaData, totalUnits, transferRestriction, allowList, overrides }: MintParams) => { + // FYI: one test case is temporally coupled with this.getConnected() const { account } = this.getConnected(); - let root; let tree; if (allowList) { @@ -189,10 +194,13 @@ export class HypercertClient implements HypercertClientInterface { allowListEntries = lines.map((line) => { const values = line.split(","); - const entry = headers.reduce((acc, header, i) => { - acc[header] = values[i]; - return acc; - }, {} as Record); + const entry = headers.reduce( + (acc, header, i) => { + acc[header] = values[i]; + return acc; + }, + {} as Record, + ); const { address, units } = entry; return { address, units: BigInt(units) }; }); @@ -206,8 +214,6 @@ export class HypercertClient implements HypercertClientInterface { if (!tree) { throw new ClientError("Invalid or no contents found for the provided allow list", { allowList }); } - - root = tree.root; } if (allowList && !tree) { @@ -234,10 +240,21 @@ export class HypercertClient implements HypercertClientInterface { const cid = metadataRes.data.data?.cid; const method = allowList && tree ? "createAllowlist" : "mintClaim"; + const accountAddress = overrides?.safeAddress ?? account.address; const params = allowList && tree - ? [account?.address, totalUnits, root, cid, transferRestriction] - : [account?.address, totalUnits, cid, transferRestriction]; + ? [accountAddress, totalUnits, tree.root, cid, transferRestriction] + : [accountAddress, totalUnits, cid, transferRestriction]; + + // If a safe address is provided, use the SafeTransactions class to mint the hypercert + if (overrides?.safeAddress) { + if (!this._walletClient) { + throw new ClientError("Safe address provided but no wallet client found"); + } + + const safeTransactions = new SafeTransactions(overrides.safeAddress, this._walletClient, this._getContract()); + return safeTransactions.mintHypercert(method, params, overrides); + } const request = await this.simulateRequest(account, method, params, overrides); return this.submitRequest(request); @@ -589,16 +606,6 @@ export class HypercertClient implements HypercertClientInterface { }); }; - private getCleanedOverrides = (overrides?: SupportedOverrides) => { - const _overrides = { - value: overrides?.value, - gas: overrides?.gasLimit, - gasPrice: overrides?.gasPrice, - }; - - return Object.fromEntries(Object.entries(_overrides).filter(([_, value]) => value !== undefined)); - }; - private getConnected = () => { if (!this._walletClient) { throw new ClientError("Could not connect to wallet; sending transactions not allowed.", { client: this }); @@ -631,7 +638,7 @@ export class HypercertClient implements HypercertClientInterface { args, abi: HypercertMinterAbi, address: readContract.address, - ...this.getCleanedOverrides(overrides), + ...onlyProvidedContractOverrides(overrides), }); return request; diff --git a/src/safe/SafeTransactions.ts b/src/safe/SafeTransactions.ts new file mode 100644 index 0000000..eff6902 --- /dev/null +++ b/src/safe/SafeTransactions.ts @@ -0,0 +1,97 @@ +import { GetContractReturnType, WalletClient, encodeFunctionData } from "viem"; +import { HypercertMinterAbi } from "@hypercerts-org/contracts"; +import Safe from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; + +import { ClientError, ContractOverrides, SafeTransactionError, SupportedOverrides } from "src/types"; +import { onlyProvidedContractOverrides } from "src/utils/overrides"; + +type SafeTx = { + to: string; + data: string; + value: string; +}; + +// The expect error statements are due to the fact that the SDKs are CommonJS modules. +export class SafeTransactions { + private readonly apiKit: SafeApiKit; + + constructor( + private safeAddress: string, + private walletClient: WalletClient, + private contract: GetContractReturnType, + ) { + if (!walletClient.chain?.id) { + throw new Error("No chain ID found in wallet client"); + } + // @ts-expect-error Property 'default' does not exist on type 'typeof SafeApiKit' + this.apiKit = new SafeApiKit.default({ + chainId: BigInt(walletClient.chain.id), + }); + } + + public mintHypercert = ( + functionName: string, + params: unknown[], + overrides?: SupportedOverrides, + ): Promise<`0x${string}`> => { + const transactions = [ + { + to: this.contract.address, + data: encodeFunctionData({ + abi: this.contract.abi, + functionName, + args: params, + }), + value: "0", + }, + ]; + + return this.performSafeTransactions(this.safeAddress, transactions, onlyProvidedContractOverrides(overrides)); + }; + + private performSafeTransactions = async ( + safeAddress: string, + transactions: SafeTx[], + overrides?: ContractOverrides, + ): Promise<`0x${string}`> => { + const senderAddress = this.walletClient.account?.address; + if (!senderAddress) { + throw new ClientError("No sender address"); + } + + try { + // @ts-expect-error Property 'default' does not exist on type 'typeof Safe' + const protocolKit = await Safe.default.init({ + provider: this.walletClient as any, + safeAddress: safeAddress, + }); + const connected = await protocolKit.connect(this.walletClient as any); + + const nonceString = await this.apiKit.getNextNonce(safeAddress); + const safeTx = await connected.createTransaction({ + transactions, + options: { + nonce: Number(nonceString), + ...onlyProvidedContractOverrides(overrides), + }, + }); + const safeTxHash = await connected.getTransactionHash(safeTx); + const senderSignature = await connected.signHash(safeTxHash); + + await this.apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: safeTx.data, + safeTxHash, + senderAddress, + senderSignature: senderSignature.data, + }); + + return safeTxHash as `0x${string}`; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Safe transaction failed"; + const errorPayload = { safeAddress, senderAddress, transactions }; + throw new SafeTransactionError(errorMessage, errorPayload); + } + }; +} diff --git a/src/types/client.ts b/src/types/client.ts index 5135f82..51df1c9 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -1,7 +1,7 @@ import { AllowlistEntry, TransferRestrictions } from "./hypercerts"; import { HypercertMetadata } from "./metadata"; -import { ByteArray, Hex, PublicClient, WalletClient } from "viem"; +import { Address, ByteArray, Hex, PublicClient, WalletClient } from "viem"; import { AxiosRequestConfig } from "axios"; export type TestChainIds = 11155111 | 84532 | 421614 | 314159; @@ -13,7 +13,7 @@ export type ProductionChainIds = 10 | 42220 | 8453 | 42161 | 314; */ export type SupportedChainIds = TestChainIds | ProductionChainIds; -export type SupportedOverrides = ContractOverrides & AxiosRequestConfig; +export type SupportedOverrides = ContractOverrides & FeatureOverrides & AxiosRequestConfig; /** * Configuration options for the contract interactions. @@ -28,6 +28,15 @@ export type ContractOverrides = { gasLimit?: bigint; }; +/** + * Configuration options that change how the client behaves. + * + * @param safeAddress When provided, the client will use the Safe to send transactions. + */ +export type FeatureOverrides = { + safeAddress?: Address; +}; + export type Contracts = | "HypercertMinterUUPS" | "TransferManager" diff --git a/src/types/errors.ts b/src/types/errors.ts index 5da1183..3544775 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -192,6 +192,18 @@ export class ConfigurationError extends Error implements CustomError { } } +/** + * The Safe transaction failed + */ +export class SafeTransactionError extends Error implements CustomError { + payload?: { [key: string]: unknown }; + + constructor(message: string, payload?: { [key: string]: unknown }) { + super(message); + this.payload = payload; + } +} + export type HypercertsSdkError = | ConfigurationError | FetchError diff --git a/src/utils/allowlist.ts b/src/utils/allowlist.ts index ff725e9..e5dc69c 100644 --- a/src/utils/allowlist.ts +++ b/src/utils/allowlist.ts @@ -20,7 +20,7 @@ const parseDataToOzMerkleTree = (data: unknown, uri?: string) => { }; const parseAllowListEntriesToMerkleTree = (allowList: AllowlistEntry[]) => { - const tuples = allowList.map((p) => [p.address, p.units]); + const tuples = allowList.map((p) => [p.address, p.units] as [string, bigint]); return StandardMerkleTree.of(tuples, ["address", "uint256"]); }; @@ -32,7 +32,7 @@ const getMerkleTreeFromIPFS = async (cidOrIpfsUri: string) => { throw new Error(`Invalid allowlist at ${cidOrIpfsUri}`); } - const tree = StandardMerkleTree.load(JSON.parse(allowlist)); + const tree = StandardMerkleTree.load<[string, bigint]>(JSON.parse(allowlist)); if (!tree) { throw new Error(`Invalid allowlist at ${cidOrIpfsUri}`); @@ -41,6 +41,7 @@ const getMerkleTreeFromIPFS = async (cidOrIpfsUri: string) => { return tree; }; +// TODO: This function is not used anywhere, we should remove it. /** * This function retrieves proofs from an allowlist. * diff --git a/src/utils/overrides.ts b/src/utils/overrides.ts new file mode 100644 index 0000000..db5e8df --- /dev/null +++ b/src/utils/overrides.ts @@ -0,0 +1,11 @@ +import { SupportedOverrides } from "src/types"; + +export const onlyProvidedContractOverrides = (overrides?: SupportedOverrides) => { + const _overrides = { + value: overrides?.value, + gas: overrides?.gasLimit, + gasPrice: overrides?.gasPrice, + }; + + return Object.fromEntries(Object.entries(_overrides).filter(([_, value]) => value !== undefined)); +}; diff --git a/test/client.test.ts b/test/client.test.ts index e5b5d1e..d09b3e1 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -38,6 +38,7 @@ describe("HypercertClient setup tests", () => { const client = new HypercertClient({ environment: "test", publicClient }); // mintClaim + // This test case is a bit brittle as it relies on the getConnected() method being called at the top of mintHypercert() try { const metaData = { name: "test" } as HypercertMetadata; const totalUnits = 1n; diff --git a/test/safe/transactions.test.ts b/test/safe/transactions.test.ts new file mode 100644 index 0000000..ee0e74e --- /dev/null +++ b/test/safe/transactions.test.ts @@ -0,0 +1,215 @@ +import { describe, it, beforeEach } from "vitest"; +import { encodeFunctionData } from "viem"; +import { faker } from "@faker-js/faker"; +import { HypercertMinterAbi } from "@hypercerts-org/contracts"; +import assertionsCount from "chai-assertions-count"; +import chai, { expect } from "chai"; +import Safe from "@safe-global/protocol-kit"; +import SafeApiKit from "@safe-global/api-kit"; +import sinon from "sinon"; + +import { ClientError, SafeTransactionError } from "../../src/types"; +import { SafeTransactions } from "../../src/safe/SafeTransactions"; +import { walletClient } from "../helpers"; + +chai.use(assertionsCount); + +describe("SafeTransactions", () => { + const safeAddress = faker.finance.ethereumAddress(); + const contractAddress = faker.finance.ethereumAddress(); + const senderAddress = walletClient.account?.address; + const mockTxHash = faker.string.hexadecimal({ length: 64 }) as `0x${string}`; + const mockSignature = faker.string.hexadecimal({ length: 130 }) as `0x${string}`; + + let safeApiKitStub: any; + let connectedSafeStub: any; + let safeProtocolKitStub: any; + let safeTransactions: SafeTransactions; + + beforeEach(() => { + sinon.restore(); + chai.Assertion.resetAssertsCheck(); + + connectedSafeStub = createConnectedSafeStub(contractAddress, mockTxHash, mockSignature); + safeProtocolKitStub = createSafeProtocolKitStub(connectedSafeStub); + safeApiKitStub = createSafeApiKitStub(); + + safeTransactions = new SafeTransactions(safeAddress, walletClient, { + address: contractAddress, + abi: HypercertMinterAbi, + } as any); + }); + + describe("mintHypercert", () => { + const validParams = [senderAddress, 1000n, "0xcid", 0]; + + it("correctly encodes function data for transaction", async () => { + const functionName = "mintClaim"; + + const expectedData = encodeFunctionData({ + abi: HypercertMinterAbi, + functionName, + args: validParams, + }); + + await safeTransactions.mintHypercert(functionName, validParams, { + safeAddress, + }); + + expect(connectedSafeStub.createTransaction.getCall(0).args[0]).to.deep.include({ + transactions: [ + { + to: contractAddress, + data: expectedData, + value: "0", + }, + ], + }); + }); + + it("uses correct nonce from API", async () => { + safeApiKitStub.getNextNonce.resolves("42"); + + await safeTransactions.mintHypercert("mintClaim", validParams, { + safeAddress, + }); + + expect(connectedSafeStub.createTransaction.getCall(0).args[0].options).to.deep.include({ nonce: Number("42") }); + }); + }); + + describe("performSafeTransactions", () => { + const validParams = [senderAddress, 1000n, "0xcid", 0]; + + it("throws error when no sender address available", async () => { + chai.Assertion.expectAssertions(2); + + const invalidWalletClient = { ...walletClient, account: undefined }; + safeTransactions = new SafeTransactions( + safeAddress, + invalidWalletClient as any, + { + address: contractAddress, + abi: HypercertMinterAbi, + } as any, + ); + + try { + await safeTransactions.mintHypercert("mintClaim", validParams, { safeAddress }); + expect.fail("Should throw ClientError"); + } catch (e) { + expect(e).to.be.instanceOf(ClientError); + expect((e as ClientError).message).to.eq("No sender address"); + } + }); + + it("follows complete transaction flow", async () => { + const hash = await safeTransactions.mintHypercert("mintClaim", validParams, { + safeAddress, + }); + + expect(safeApiKitStub.getNextNonce.callCount).to.eq(1); + expect(connectedSafeStub.createTransaction.callCount).to.eq(1); + expect(connectedSafeStub.getTransactionHash.callCount).to.eq(1); + expect(connectedSafeStub.signHash.callCount).to.eq(1); + expect(safeApiKitStub.proposeTransaction.callCount).to.eq(1); + expect(hash).to.eq(mockTxHash); + }); + + it("properly handles Safe API errors", async () => { + chai.Assertion.expectAssertions(3); + + const errorMessage = "API Connection Failed"; + safeApiKitStub.proposeTransaction.rejects(new Error(errorMessage)); + + try { + await safeTransactions.mintHypercert("mintClaim", validParams, { safeAddress }); + expect.fail("Should throw SafeTransactionError"); + } catch (e) { + expect(e).to.be.instanceOf(SafeTransactionError); + expect((e as SafeTransactionError).message).to.eq(errorMessage); + expect((e as SafeTransactionError).payload).to.deep.include({ + safeAddress, + senderAddress, + }); + } + }); + + it("properly handles Safe Protocol Kit errors", async () => { + chai.Assertion.expectAssertions(3); + + const errorMessage = "Failed to create transaction"; + connectedSafeStub.createTransaction.rejects(new Error(errorMessage)); + + try { + await safeTransactions.mintHypercert("mintClaim", [senderAddress, 1000n, "0xcid", 0], { safeAddress }); + expect.fail("Should throw SafeTransactionError"); + } catch (e) { + expect(e).to.be.instanceOf(SafeTransactionError); + expect((e as SafeTransactionError).message).to.eq(errorMessage); + expect((e as SafeTransactionError).payload).to.deep.include({ + safeAddress, + senderAddress, + }); + } + }); + + it("properly proposes transaction with correct parameters", async () => { + await safeTransactions.mintHypercert("mintClaim", [senderAddress, 1000n, "0xcid", 0], { safeAddress }); + + const proposeCall = safeApiKitStub.proposeTransaction.getCall(0); + expect(proposeCall.args[0]).to.deep.include({ + safeAddress, + safeTransactionData: { + to: contractAddress, + value: "0", + data: "0xmockdata", + }, + safeTxHash: mockTxHash, + senderAddress, + senderSignature: mockSignature, + }); + }); + }); +}); + +function createConnectedSafeStub(contractAddress: string, mockTxHash: `0x${string}`, mockSignature: `0x${string}`) { + return { + createTransaction: sinon.stub().resolves({ + data: { + to: contractAddress, + value: "0", + data: "0xmockdata", + }, + }), + getTransactionHash: sinon.stub().resolves(mockTxHash), + signHash: sinon.stub().resolves({ data: mockSignature }), + }; +} + +function createSafeProtocolKitStub(connectedSafe: any) { + const protocolKitStub = { + connect: sinon.stub().resolves(connectedSafe), + }; + + const SafeMock = { + init: sinon.stub().resolves(protocolKitStub), + }; + (Safe as any).default = SafeMock; + + return protocolKitStub; +} + +function createSafeApiKitStub() { + const apiKitStub = { + getNextNonce: sinon.stub().resolves("1"), + proposeTransaction: sinon.stub().resolves(), + }; + + const MockSafeApiKit = sinon.stub().returns(apiKitStub); + // @ts-expect-error - Property 'default' does not exist on type 'SinonStub'. + MockSafeApiKit.default = MockSafeApiKit; + (SafeApiKit as any).default = MockSafeApiKit; + + return apiKitStub; +}