diff --git a/integration/external/Zerodev.test.ts b/integration/external/Zerodev.test.ts new file mode 100644 index 000000000..275f124ea --- /dev/null +++ b/integration/external/Zerodev.test.ts @@ -0,0 +1,215 @@ +import { ethers } from 'ethers' +import { ZeroDevEthersProvider } from '@zerodev/sdk' +import { verifyMessage } from '@ambire/signature-validator' +import * as fs from 'fs' +import { + Account, + AssetAttributes, + AssetPrice, + DDO, + MetaData, + Nevermined, + convertEthersV6SignerToAccountSigner, +} from '../../src' +import { assert } from 'chai' +import { decodeJwt } from 'jose' +import { config } from '../config' +import { getMetadata } from '../utils' + +describe('Nevermined sdk with zerodev', () => { + let nevermined: Nevermined + + before(async () => { + nevermined = await Nevermined.getInstance(config) + }) + + describe('Test zerodev signatures and login', () => { + let zerodevProvider: ZeroDevEthersProvider<'ECDSA'> + let clientAssertion: string + + before(async () => { + const projectId = process.env.PROJECT_ID! + const owner = ethers.Wallet.createRandom() + + zerodevProvider = await ZeroDevEthersProvider.init('ECDSA', { + projectId, + owner: convertEthersV6SignerToAccountSigner(owner), + }) + }) + + it('should produce a valid EIP-6492 signature', async () => { + const signer = zerodevProvider.getAccountSigner() + + const signature = await signer.signMessageWith6492('nevermined') + const isValidSignature = await verifyMessage({ + signer: await signer.getAddress(), + message: 'nevermined', + signature: signature, + provider: zerodevProvider, + }) + + assert.isTrue(isValidSignature) + }) + + it('should provide a valid EIP-6492 typed signature', async () => { + const domain = { + name: 'Nevermined', + version: '1', + chainId: 80001, + } + const types = { + Nevermined: [{ name: 'message', type: 'string' }], + } + const message = { + message: 'nevermined', + } + + const signer = zerodevProvider.getAccountSigner() + const signature = await signer.signTypedDataWith6492({ + domain, + types, + message, + primaryType: '', + }) + + const isValidSignature = await verifyMessage({ + signer: await signer.getAddress(), + signature: signature, + typedData: { + types, + domain, + message, + }, + provider: zerodevProvider, + }) + + assert.isTrue(isValidSignature) + }) + + it('should generate a client assertion with a zerodev signer', async () => { + const signer = zerodevProvider.getAccountSigner() + const account = await Account.fromZeroDevSigner(signer) + + clientAssertion = await nevermined.utils.jwt.generateClientAssertion(account, 'hello world') + assert.isDefined(clientAssertion) + + const jwtPayload = decodeJwt(clientAssertion) + assert.equal(jwtPayload.iss, await signer.getAddress()) + }) + + it('should login to the marketplace api', async () => { + const accessToken = await nevermined.services.marketplace.login(clientAssertion) + assert.isDefined(accessToken) + + const jwtPayload = decodeJwt(accessToken) + const signer = zerodevProvider.getAccountSigner() + assert.equal(jwtPayload.iss, await signer.getAddress()) + assert.isDefined(jwtPayload.sub) + }) + }) + + describe('E2E Asset flow with zerodev', () => { + let zerodevProviderPublisher: ZeroDevEthersProvider<'ECDSA'> + let zerodevProviderConsumer: ZeroDevEthersProvider<'ECDSA'> + let metadata: MetaData + let ddo: DDO + let agreementId: string + + before(async () => { + const projectId = process.env.PROJECT_ID! + const publisher = ethers.Wallet.createRandom() + const consumer = ethers.Wallet.createRandom() + + zerodevProviderPublisher = await ZeroDevEthersProvider.init('ECDSA', { + projectId, + owner: convertEthersV6SignerToAccountSigner(publisher), + }) + + zerodevProviderConsumer = await ZeroDevEthersProvider.init('ECDSA', { + projectId, + owner: convertEthersV6SignerToAccountSigner(consumer), + }) + + const signerPublisher = zerodevProviderPublisher.getAccountSigner() + const accountPublisher = await Account.fromZeroDevSigner(signerPublisher) + const clientAssertion = await nevermined.utils.jwt.generateClientAssertion(accountPublisher) + + const accessToken = await nevermined.services.marketplace.login(clientAssertion) + const payload = decodeJwt(accessToken) + + metadata = getMetadata() + metadata.userId = payload.sub + }) + + it('should register an asset with a zerodev account', async () => { + const assetAttributes = AssetAttributes.getInstance({ + metadata, + services: [ + { + serviceType: 'access', + price: new AssetPrice(), + }, + ], + providers: [config.neverminedNodeAddress], + }) + + const signerPublisher = zerodevProviderPublisher.getAccountSigner() + const publisher = await Account.fromZeroDevSigner(signerPublisher) + ddo = await nevermined.assets.create(assetAttributes, publisher, undefined, { + zeroDevSigner: signerPublisher, + }) + + assert.isDefined(ddo) + assert.equal(ddo.publicKey[0].owner, await signerPublisher.getAddress()) + assert.equal(ddo.proof.creator, await signerPublisher.getAddress()) + }) + + it('owner should be able to download the asset', async () => { + const signerPublisher = zerodevProviderPublisher.getAccountSigner() + const publisher = await Account.fromZeroDevSigner(signerPublisher) + const folder = '/tmp/nevermined/sdk-js' + + const path = (await nevermined.assets.download(ddo.id, publisher, folder, -1)) as string + const files = await new Promise((resolve) => { + fs.readdir(path, (e, fileList) => { + resolve(fileList) + }) + }) + + assert.deepEqual(files, ['README.md', 'ddo-example.json']) + }) + + it('consumer should be able to order the asset with a zerodev account', async () => { + const signerConsumer = zerodevProviderConsumer.getAccountSigner() + const consumer = await Account.fromZeroDevSigner(signerConsumer) + agreementId = await nevermined.assets.order(ddo.id, 'access', consumer, { + zeroDevSigner: signerConsumer, + }) + + assert.isDefined(agreementId) + }) + + it('consumer should be able to access ordered assets with zerodev account', async () => { + const signerConsumer = zerodevProviderConsumer.getAccountSigner() + const consumer = await Account.fromZeroDevSigner(signerConsumer) + const folder = '/tmp/nevermined/sdk-js' + + const path = (await nevermined.assets.access( + agreementId, + ddo.id, + 'access', + consumer, + folder, + -1, + )) as string + + const files = await new Promise((resolve) => { + fs.readdir(path, (e, fileList) => { + resolve(fileList) + }) + }) + + assert.deepEqual(files, ['README.md', 'ddo-example.json']) + }) + }) +}) diff --git a/integration/tsconfig.json b/integration/tsconfig.json index bd2f066ca..4dcca442c 100644 --- a/integration/tsconfig.json +++ b/integration/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "moduleResolution": "NodeNext", + "moduleResolution": "Node", "resolveJsonModule": true, "lib": ["es6", "es7", "dom", "ES2020"], "target": "ES2020", diff --git a/package.json b/package.json index 770f35222..ea655ee48 100644 --- a/package.json +++ b/package.json @@ -51,15 +51,17 @@ }, "homepage": "https://github.com/nevermined-io/sdk-js#readme", "dependencies": { + "@alchemy/aa-core": "0.1.0", "@apollo/client": "^3.7.16", + "@zerodev/sdk": "4.0.30", "assert": "^2.0.0", "cross-fetch": "^4.0.0", "crypto-browserify": "^3.12.0", "deprecated-decorator": "^0.1.6", "ethers": "^6.7.1", + "form-data": "^4.0.0", "graphql": "^16.7.1", "https-browserify": "^1.0.0", - "form-data": "^4.0.0", "jose": "^4.5.1", "js-file-download": "^0.4.12", "lodash": "^4.17.21", @@ -75,6 +77,7 @@ "whatwg-url": "^13.0.0" }, "devDependencies": { + "@ambire/signature-validator": "^1.3.1", "@commitlint/cli": "^17.4.2", "@commitlint/config-conventional": "^17.4.2", "@faker-js/faker": "^6.3.1", diff --git a/src/keeper/contracts/ContractBase.ts b/src/keeper/contracts/ContractBase.ts index 5a87ea048..f9c05c006 100644 --- a/src/keeper/contracts/ContractBase.ts +++ b/src/keeper/contracts/ContractBase.ts @@ -6,9 +6,11 @@ import { ContractTransactionReceipt, ContractTransactionResponse, FunctionFragment, + TransactionReceipt, ethers, } from 'ethers' import { jsonReplacer, parseUnits } from '../../sdk' +import { ZeroDevAccountSigner } from '@zerodev/sdk' export interface TxParameters { value?: string gasLimit?: bigint @@ -17,6 +19,7 @@ export interface TxParameters { maxPriorityFeePerGas?: string maxFeePerGas?: string signer?: ethers.Signer + zeroDevSigner?: ZeroDevAccountSigner<'ECDSA'> nonce?: number progress?: (data: any) => void } @@ -162,12 +165,92 @@ export abstract class ContractBase extends Instantiable { return transactionReceipt } + private async internalSendZeroDev( + name: string, + from: string, + args: any[], + txparams: any, + contract: ethers.BaseContract, + progress: (data: any) => void, + ): Promise { + const methodSignature = this.getSignatureOfMethod(name, args) + // Uncomment to debug contract calls + // console.debug(`Making contract call ....: ${name} - ${from}`) + // console.debug(`With args - ${JSON.stringify(args)}`) + // console.debug(`And signature - ${methodSignature}`) + + const { gasLimit, value } = txparams + // make the call + if (progress) { + progress({ + stage: 'sending', + args: this.searchMethodInputs(name, args), + method: name, + from, + value, + contractName: this.contractName, + contractAddress: this.address, + gasLimit, + }) + } + + const transactionResponse: ContractTransactionResponse = await contract[methodSignature]( + ...args, + txparams, + ) + if (progress) { + progress({ + stage: 'sent', + args: this.searchMethodInputs(name, args), + transactionResponse, + method: name, + from, + value, + contractName: this.contractName, + contractAddress: this.address, + gasLimit, + }) + } + + const transactionReceipt: TransactionReceipt = + await transactionResponse.provider.waitForTransaction(transactionResponse.hash) + + if (progress) { + progress({ + stage: 'receipt', + args: this.searchMethodInputs(name, args), + transactionReceipt, + method: name, + from, + value, + contractName: this.contractName, + contractAddress: this.address, + gasLimit, + }) + } + + return transactionReceipt as ContractTransactionReceipt + } + public async send( name: string, from: string, args: any[], params: TxParameters = {}, ): Promise { + if (params.zeroDevSigner) { + const paramsFixed = { ...params, signer: undefined } + const contract = this.contract.connect(params.zeroDevSigner as any) + return await this.internalSendZeroDev( + name, + from, + args, + paramsFixed, + contract, + params.progress, + ) + } + if (params.signer) { const paramsFixed = { ...params, signer: undefined } const contract = this.contract.connect(params.signer) diff --git a/src/keeper/utils.ts b/src/keeper/utils.ts index ff33a2ec3..dfdbb65fc 100644 --- a/src/keeper/utils.ts +++ b/src/keeper/utils.ts @@ -1,4 +1,5 @@ -import { ethers } from 'ethers' +import { Signer, TypedDataField, Wallet, ethers } from 'ethers' +import { SmartAccountSigner, SignTypedDataParams, Hex } from '@alchemy/aa-core' import { KeeperError } from '../errors' export async function getNetworkName(networkId: number): Promise { @@ -91,3 +92,28 @@ export class Web3ProviderWrapper { .then((result) => callback(null, { jsonrpc: payload.jsonrpc, id, result })) } } + +const isWalletEthersV6 = (signer: any): signer is Wallet => + signer && signer.signTypedData !== undefined + +// zerodev ethersV6 compatibility +export const convertEthersV6SignerToAccountSigner = ( + signer: Signer | Wallet, +): SmartAccountSigner => { + return { + signerType: '', + getAddress: async () => Promise.resolve((await signer.getAddress()) as `0x${string}`), + signMessage: async (msg: Uint8Array | string) => + (await signer.signMessage(msg)) as `0x${string}`, + signTypedData: async (params: SignTypedDataParams) => { + if (!isWalletEthersV6(signer)) { + throw Error('signTypedData method not implemented in signer') + } + return (await signer.signTypedData( + params.domain!, + params.types as unknown as Record, + params.message, + )) as Hex + }, + } +} diff --git a/src/nevermined/Account.ts b/src/nevermined/Account.ts index aa7c9725b..40b2c78b2 100644 --- a/src/nevermined/Account.ts +++ b/src/nevermined/Account.ts @@ -4,18 +4,17 @@ import { Instantiable, InstantiableConfig } from '../Instantiable.abstract' import { TxParameters } from '../keeper' import { KeeperError } from '../errors' import { BigNumberish } from '../sdk' +import { ZeroDevAccountSigner } from '@zerodev/sdk' /** * Account information. */ export class Account extends Instantiable { private password?: string - - private token?: string - public babyX?: string public babyY?: string public babySecret?: string + public zeroDevSigner: ZeroDevAccountSigner<'ECDSA'> constructor(private id: string = '0x0', config?: InstantiableConfig) { super() @@ -24,6 +23,23 @@ export class Account extends Instantiable { } } + /** + * Returns a nevermined Account from a zerodev signer + * + * @param signer - A zerodev account signer + * @returns The nevermined account + */ + static async fromZeroDevSigner(signer: ZeroDevAccountSigner<'ECDSA'>): Promise { + const address = await signer.getAddress() + const account = new Account(address) + account.zeroDevSigner = signer + return account + } + + public isZeroDev(): boolean { + return this.zeroDevSigner !== undefined + } + public getId() { return this.id } diff --git a/src/nevermined/api/RegistryBaseApi.ts b/src/nevermined/api/RegistryBaseApi.ts index 8f32390d9..dd8b748b7 100644 --- a/src/nevermined/api/RegistryBaseApi.ts +++ b/src/nevermined/api/RegistryBaseApi.ts @@ -575,10 +575,6 @@ export abstract class RegistryBaseApi extends Instantiable { const service = ddo.findServiceByReference(serviceReference) const templateName = service.attributes.serviceAgreementTemplate.contractName - console.log( - `Ordering Asset with reference ${serviceReference}, template ${templateName} and price ${service.attributes.main.price}`, - ) - const template = keeper.getAccessTemplateByName(templateName) this.logger.log(`Creating ${serviceReference} agreement and paying`) diff --git a/src/nevermined/utils/JwtUtils.ts b/src/nevermined/utils/JwtUtils.ts index d3491ff75..3ac99c984 100644 --- a/src/nevermined/utils/JwtUtils.ts +++ b/src/nevermined/utils/JwtUtils.ts @@ -3,12 +3,26 @@ import { Instantiable, InstantiableConfig } from '../../Instantiable.abstract' import { Account } from '../Account' import { ethers } from 'ethers' import { Babysig } from '../../models' +import { ZeroDevAccountSigner } from '@zerodev/sdk' export interface Eip712Data { message: string chainId: number } +export interface TypedDataDomain { + name: string + version: string + chainId: number +} + +export interface TypedDataTypes { + Nevermined: { + name: string + type: string + }[] +} + export class EthSignJWT extends SignJWT { protectedHeader: JWSHeaderParameters @@ -17,7 +31,10 @@ export class EthSignJWT extends SignJWT { return this } - public async ethSign(signer: ethers.Signer, eip712Data?: Eip712Data): Promise { + public async ethSign( + signer: ethers.Signer | ZeroDevAccountSigner<'ECDSA'>, + eip712Data?: Eip712Data, + ): Promise { const encoder = new TextEncoder() const decoder = new TextDecoder() @@ -55,9 +72,9 @@ export class EthSignJWT extends SignJWT { message: eip712Data.message, token: decoder.decode(data), } - sign = await signer.signTypedData(domain, types, value) + sign = await EthSignJWT.signTypedMessage(domain, types, value, signer) } else { - sign = await EthSignJWT.signText(decoder.decode(data), signer) + sign = await EthSignJWT.signMessage(decoder.decode(data), signer) } const input = ethers.getBytes(sign) @@ -80,6 +97,34 @@ export class EthSignJWT extends SignJWT { } } + private static async signMessage( + message: string | Uint8Array, + signer: ethers.Signer | ZeroDevAccountSigner<'ECDSA'>, + ): Promise { + if (signer instanceof ZeroDevAccountSigner) { + return signer.signMessageWith6492(message) + } + + return EthSignJWT.signText(message, signer) + } + + private static async signTypedMessage( + domain: TypedDataDomain, + types: TypedDataTypes, + value: Record, + signer: ethers.Signer | ZeroDevAccountSigner<'ECDSA'>, + ): Promise { + if (signer instanceof ZeroDevAccountSigner) { + return signer.signTypedDataWith6492({ + domain, + types: types as any, + message: value, + primaryType: '', + }) + } + return EthSignJWT.signTypedMessage(domain, types, value, signer) + } + private base64url(input: Uint8Array | string): string { return Buffer.from(input) .toString('base64') @@ -112,6 +157,13 @@ export class JwtUtils extends Instantiable { this.tokenCache = new Map() } + public async getSigner(account: Account): Promise> { + const address = ethers.getAddress(account.getId()) + return account.isZeroDev() + ? account.zeroDevSigner + : await this.nevermined.accounts.findSigner(address) + } + public generateCacheKey(...args: string[]): string { return args.join() } @@ -138,9 +190,6 @@ export class JwtUtils extends Instantiable { } public async generateClientAssertion(account: Account, message?: string) { - const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) - let eip712Data: Eip712Data if (message) { eip712Data = { @@ -148,6 +197,9 @@ export class JwtUtils extends Instantiable { chainId: await this.nevermined.keeper.getNetworkId(), } } + + const address = ethers.getAddress(account.getId()) + const signer = await this.getSigner(account) return new EthSignJWT({ iss: address, }) @@ -165,7 +217,7 @@ export class JwtUtils extends Instantiable { babysig?: Babysig, ): Promise { const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) + const signer = await this.getSigner(account) return new EthSignJWT({ iss: address, @@ -190,7 +242,7 @@ export class JwtUtils extends Instantiable { obj: any, ): Promise { const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) + const signer = await this.getSigner(account) return new EthSignJWT({ iss: address, @@ -213,8 +265,7 @@ export class JwtUtils extends Instantiable { babysig?: Babysig, ): Promise { const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) - + const signer = await this.getSigner(account) return new EthSignJWT({ iss: address, aud: this.BASE_AUD + '/download', @@ -254,7 +305,7 @@ export class JwtUtils extends Instantiable { workflowId: string, ): Promise { const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) + const signer = await this.getSigner(account) return new EthSignJWT({ iss: address, @@ -275,7 +326,7 @@ export class JwtUtils extends Instantiable { executionId: string, ): Promise { const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) + const signer = await this.getSigner(account) return new EthSignJWT({ iss: address, @@ -299,8 +350,7 @@ export class JwtUtils extends Instantiable { babysig?: Babysig, ): Promise { const address = ethers.getAddress(account.getId()) - const signer = await this.nevermined.accounts.findSigner(address) - + const signer = await this.getSigner(account) const params = { iss: address, aud: this.BASE_AUD + '/nft-access',