diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..56217fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/node_modules/**": true + } +} \ No newline at end of file diff --git a/packages/adapters/orderbook-db/src/schema.ts b/packages/adapters/orderbook-db/src/schema.ts index 118e478..2650bda 100644 --- a/packages/adapters/orderbook-db/src/schema.ts +++ b/packages/adapters/orderbook-db/src/schema.ts @@ -2,6 +2,11 @@ import mongoose from "mongoose"; /// Order Schema /// TODO: amounts, dapps, signatures, stratrgyType need to be in a object[] +interface MetaTransactionData { + to: string; + value: string; + data: string; +} interface IOrder { id: string; signer: string; // signer address @@ -14,7 +19,7 @@ interface IOrder { dapps: string[]; distribution?: boolean; amounts: string[]; - signatures?: string[]; + signatures?: MetaTransactionData[]; status: "P" | "E" | "C"; // Pending, Executed, Canceled hashes?: string[]; // Optional array of transaction hashes strategyType: "FLEXI" | "FIXED"; @@ -30,9 +35,13 @@ const OrderSchema = new mongoose.Schema({ cancelled: { type: Date } }, dapps: [{ type: String, required: true }], - distribution: { type: Boolean, required: true }, + distribution: { type: Boolean, required: false }, amounts: [{ type: String, required: true }], - signatures: [{ type: String, required: true }], + signatures: [{ + to: { type: String, required: true }, + value: { type: String, required: true }, + data: { type: String, required: true } + }], status: { type: String, enum: ["P", "E", "C"], required: true }, hashes: [{ type: String }], strategyType: { type: String, enum: ["FLEXI", "FIXED"], required: true }, diff --git a/packages/agents/nova/src/core/resources/orderbook/dto/approve.dto.ts b/packages/agents/nova/src/core/resources/orderbook/dto/approve.dto.ts index ffb33c5..69ab169 100644 --- a/packages/agents/nova/src/core/resources/orderbook/dto/approve.dto.ts +++ b/packages/agents/nova/src/core/resources/orderbook/dto/approve.dto.ts @@ -2,23 +2,33 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsIn, IsNotEmpty, IsString, Length } from 'class-validator'; import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'; import { NovaResponse } from 'src/common/interfaces/nova-response.interface'; +import { TransactionRequest, TypedDataEncoder } from 'ethers'; export class OrderbookGetApproveTxQueryDto { @IsString() @IsNotEmpty() @Length(42, 42) @ApiProperty({ - description: 'The asset address', - example: '0x0617b72940f105811F251967EE4fdD5E38f159d5', + description: 'Asset address', + example: '0x83A9aE82b26249EC6e01498F5aDf0Ec20fF3Da9C', }) assetAddress: string; @IsString() @IsNotEmpty() @Length(42, 42) + @ApiProperty({ + description: 'User SCW address', + example: '0xC7E0F1883aD6DABBA9a6a440beeBD2Bfe4851758', + }) + walletAddress: string; + + + @IsString() + @IsNotEmpty() @ApiProperty({ description: 'The amount of token to be approved', - example: '10000000', + example: '1000000', }) amount: string; } @@ -28,25 +38,30 @@ export class OrderbookSendApproveTxQueryDto { @IsNotEmpty() @Length(42, 42) @ApiProperty({ - description: 'The user wallet address', + description: 'User\'s EOA Address', example: '0x0617b72940f105811F251967EE4fdD5E38f159d5', }) - userWalletAddress: string; + signerAddress: string; @IsString() @IsNotEmpty() @Length(42, 42) @ApiProperty({ - description: 'The user scw address', + description: 'User\'s Safe Wallet Address', example: '0x0617b72940f105811F251967EE4fdD5E38f159d5', }) - userScwAddress: string; + walletAddress: string; + + @ApiProperty({ + description: 'Meta transation data received from get approve tx', + }) + metaTransaction: MetaTransactionData; @IsNotEmpty() @ApiProperty({ description: 'The signed tx', }) - userSignedTransaction: MetaTransactionData; + signature: string; @IsIn(['FLEXI', 'FIXED']) @IsNotEmpty() @@ -58,24 +73,29 @@ export class OrderbookSendApproveTxQueryDto { @IsString() @IsNotEmpty() - @Length(42, 42) @ApiProperty({ description: 'The amount of token to be approved', - example: '10000000', + example: '1000000', }) amount: string; } -export class ApyResponse { - @IsString() - @IsNotEmpty() - apy: string; +export class OrderbookGetApproveTxDataDto { + @ApiProperty({ + description: 'Raw Transaction Metadata', + }) + txData: MetaTransactionData; + @ApiProperty({ + description: 'Json Typed Data to be signed', + example: '{...}', + }) + typedData: string; } export class OrderbookGetApproveTxResponseDto implements NovaResponse { @ApiProperty({ description: 'Response message', - example: 'Apy data fetched successfully.', + example: 'Approve transaction created successfully.', }) message: string; @ApiProperty({ @@ -86,13 +106,13 @@ export class OrderbookGetApproveTxResponseDto implements NovaResponse { @ApiProperty({ description: 'Response data', }) - data: ApyResponse; + data: OrderbookGetApproveTxDataDto; } export class OrderbookSendResponseDto { @ApiProperty({ description: 'Response message', - example: 'Apy data fetched successfully.', + example: 'Transaction submitted successfully.', }) message: string; @ApiProperty({ @@ -100,4 +120,8 @@ export class OrderbookSendResponseDto { example: 201, }) statusCode: number; + @ApiProperty({ + description: 'Gelato relayer task data', + }) + data: { taskId: string }; } diff --git a/packages/agents/nova/src/core/resources/orderbook/orderbook.controller.ts b/packages/agents/nova/src/core/resources/orderbook/orderbook.controller.ts index c0947ac..c6c3c02 100644 --- a/packages/agents/nova/src/core/resources/orderbook/orderbook.controller.ts +++ b/packages/agents/nova/src/core/resources/orderbook/orderbook.controller.ts @@ -1,10 +1,7 @@ -import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; -import { OrderbookGetApproveTxQueryDto, OrderbookSendApproveTxQueryDto, OrderbookSendResponseDto } from './dto/approve.dto'; +import { OrderbookGetApproveTxDataDto, OrderbookGetApproveTxQueryDto, OrderbookGetApproveTxResponseDto, OrderbookSendApproveTxQueryDto, OrderbookSendResponseDto } from './dto/approve.dto'; import { OrderbookService } from './orderbook.service'; -import { ethers } from 'ethers'; -import { Post } from '@nestjs/common'; -import { Body } from '@nestjs/common'; @ApiTags('orderbook') @Controller('orderbook') @@ -20,12 +17,12 @@ export class OrderbookController { @ApiResponse({ status: HttpStatus.OK, description: 'Get transaction for approve', - type: OrderbookGetApproveTxQueryDto, + type: OrderbookGetApproveTxResponseDto, }) @Get('create-approve') createApproveTransaction( @Query() query: OrderbookGetApproveTxQueryDto, - ): Promise { + ): Promise { return this.orderbookService.createApproveTransaction(query); } @@ -40,7 +37,7 @@ export class OrderbookController { type: OrderbookSendResponseDto, }) @Post('send-approve') - async sendApprove(@Body() body: OrderbookSendApproveTxQueryDto): Promise { + async sendApprove(@Body() body: OrderbookSendApproveTxQueryDto): Promise<{ taskId: string }> { return await this.orderbookService.sendApproveTransaction(body); } diff --git a/packages/agents/nova/src/core/resources/orderbook/orderbook.service.ts b/packages/agents/nova/src/core/resources/orderbook/orderbook.service.ts index a98b1ba..e9933c9 100644 --- a/packages/agents/nova/src/core/resources/orderbook/orderbook.service.ts +++ b/packages/agents/nova/src/core/resources/orderbook/orderbook.service.ts @@ -7,28 +7,33 @@ import { createTransactions, execute, executeRelayTransaction, + executeSignedTypedDataRelayTx, + getDeployedSCW, getSCW, + getSafeTxTypedData, initQW, + initSCW, + normalizeMetaTransaction, receiveFunds, relayTransaction, - signSafeTransaction, + signSafeTransaction } from '@qw/utils'; import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'; -import { ethers } from 'ethers'; +import { JsonRpcProvider, Wallet, ethers } from 'ethers'; import { ConfigService } from 'src/config/config.service'; import { NovaConfig } from 'src/config/schema'; import { v4 as uuidv4 } from 'uuid'; -import { OrderbookGetApproveTxQueryDto, OrderbookSendApproveTxQueryDto } from './dto/approve.dto'; +import { OrderbookGetApproveTxDataDto, OrderbookGetApproveTxQueryDto, OrderbookSendApproveTxQueryDto } from './dto/approve.dto'; @Injectable() export class OrderbookService { @Inject('ORDER_MODEL') private orderModel: typeof OrderModel = OrderModel; private readonly logger = new Logger(OrderbookService.name); - private wallet; + private wallet: Wallet; private config: NovaConfig; - public signer; - public provider; + public signer: Wallet; + public provider: JsonRpcProvider; constructor( private configService: ConfigService, @@ -54,73 +59,95 @@ export class OrderbookService { /** * Creates an approval transaction to allow spending tokens from the user's wallet. * @param query The query containing asset address and amount details. - * @returns A promise that resolves to an ethers.TransactionRequest object representing the approval transaction. + * @returns A promise that resolves to an Transaction object representing the approval transaction. */ - async createApproveTransaction(query: OrderbookGetApproveTxQueryDto): Promise { - const { assetAddress, amount } = query; - const qwManagerAddress = - this.config.chains[0].contractAddresses.QWManager.address; + async createApproveTransaction(query: OrderbookGetApproveTxQueryDto): Promise { + try { + this.logger.log('Creating approve transaction:', query); + const { assetAddress, walletAddress, amount } = query; + const qwManagerAddress = + Object.values(this.config.chains)[0].contractAddresses.QWManager.address; + + const txData = approve({ + contractAddress: assetAddress, + amount: BigInt(amount), + provider: this.provider, + spender: qwManagerAddress, + }); - return approve({ - contractAddress: assetAddress, - amount: BigInt(amount), - provider: this.provider, - spender: qwManagerAddress, - }); + const rpc = Object.values(this.config.chains)[0].providers[0]; + + // Get QW's deployed Safe's instance + const qwSafe = await getDeployedSCW({ + rpc: rpc, + safeAddress: walletAddress, + signer: this.config.privateKey, + }); + + const typedData = await getSafeTxTypedData({ + protocolKit: qwSafe, + txData: txData, + }); + + return { + txData: txData, + typedData: JSON.stringify(typedData), + } + } catch (error) { + this.logger.error('Error creating approve transaction:', error); + } } /** * Creates a pending order in the orderbook and sends the approval transaction to Gelato. * @param query The query containing amount, wallet addresses, signed transaction, and strategy type details. */ - async sendApproveTransaction(query: OrderbookSendApproveTxQueryDto) { - const { amount, userWalletAddress, userScwAddress, userSignedTransaction, strategyType } = query; + async sendApproveTransaction(query: OrderbookSendApproveTxQueryDto): Promise<{ taskId: string }> { + const { amount, signerAddress, walletAddress, metaTransaction, signature, strategyType } = query; const amounts = [amount]; // Currently, there is only one child contract, so the entire amount will be allocated to it. const qwAaveV3Address = '0x0000000000000000000000000000000000000123'; const dapps = [qwAaveV3Address]; try { - await this._createOrder(userWalletAddress, userScwAddress, amounts, dapps, userSignedTransaction, strategyType); - await this._sendApproveTransaction(userScwAddress, userSignedTransaction); + await this._createOrder(signerAddress, walletAddress, amounts, dapps, metaTransaction, strategyType); + return this._sendApproveTransaction(signerAddress, metaTransaction, signature); } catch (err) { + console.log(err) this.logger.error('Error sending approving tx:', err); } } private async _sendApproveTransaction( - userScwAddress: string, - userSignedTransaction: MetaTransactionData, - ) { - const rpc = this.config.chains[0].providers[0]; + signerAddress: string, + metaTransaction: MetaTransactionData, + signature: string, + ): Promise<{ taskId: string }> { + const rpc = Object.values(this.config.chains)[0].providers[0]; const gelatoApiKey = this.config.gelatoApiKey; - // Create the gelato relay pack using an initialized SCW. - const safeQW = await initQW({ - rpc, - signer: this.signer, - address: userScwAddress, + const qwSafe = await initSCW({ + rpc: rpc, + address: signerAddress, }); + + // // Create Gelato relay pack with the protocol kit and API key const gelatoRelayPack = await createGelatoRelayPack({ + protocolKit: qwSafe, gelatoApiKey, - protocolKit: safeQW, - }); - - // This will derive from MetaTransactionData and the gelato relay pack a SafeTransaction. - const safeTransaction = await relayTransaction({ - transactions: [userSignedTransaction], - gelatoRelayPack, }); - // Execute the relay transaction using gelato. - await executeRelayTransaction({ + return executeSignedTypedDataRelayTx({ + safe: qwSafe, gelatoRelayPack, - signedSafeTransaction: safeTransaction, + metaTransaction, + signer: signerAddress, + signature: signature, }); } // internal fn private async _createOrder( - userWalletAddress: string, - userScwAddress: string, + signerAddress: string, + walletAddress: string, amounts: string[], dapps: string[], userSignedTransaction: MetaTransactionData, @@ -130,8 +157,8 @@ export class OrderbookService { this.orderModel.create({ id: uuidv4(), - signer: userWalletAddress, - wallet: userScwAddress, + signer: signerAddress, + wallet: walletAddress, dapps, amounts, signatures: [userSignedTransaction], @@ -226,7 +253,7 @@ export class OrderbookService { receiveFundsRequests.concat(executeRequests); // Init the QW safe for signing/wrapping relayed batch transactions below. - const safe = await initQW({ rpc, address: qwScwAddress, signer }); + const safe = await initQW({ rpc, address: qwScwAddress, signer: this.signer.privateKey }); // First, we relay receiveFunds. { diff --git a/packages/agents/nova/src/core/resources/user/dto/user-init-body.dto.ts b/packages/agents/nova/src/core/resources/user/dto/user-init-body.dto.ts index d4413a2..6526730 100644 --- a/packages/agents/nova/src/core/resources/user/dto/user-init-body.dto.ts +++ b/packages/agents/nova/src/core/resources/user/dto/user-init-body.dto.ts @@ -8,7 +8,7 @@ export class UserInitBodyDto { @ApiProperty({ example: '0x0617b72940f105811F251967EE4fdD5E38f159d5', }) - walletAddress: string; + signerAddress: string; @IsString() @IsNotEmpty() diff --git a/packages/agents/nova/src/core/resources/user/user.service.ts b/packages/agents/nova/src/core/resources/user/user.service.ts index a598f6a..38563df 100644 --- a/packages/agents/nova/src/core/resources/user/user.service.ts +++ b/packages/agents/nova/src/core/resources/user/user.service.ts @@ -66,13 +66,13 @@ export class UserService { * It deploys the smart contract and initializes the user * @returns transaction */ - async userInit({ walletAddress, provider }: UserInitBodyDto): Promise { + async userInit({ signerAddress, provider }: UserInitBodyDto): Promise { const rpcUrl = Object.values(this.config.chains)[0].providers[0]; // RPC URL const gelatoApiKey = this.config.gelatoApiKey; const qwSafeAddress = '0xC22E238cbAb8B34Dc0014379E00B38D15D806115'; // Address of the safe // Initialize the user's smart contract wallet (SCW) - const userSafe = await initSCW({ rpc: rpcUrl, address: walletAddress }); + const userSafe = await initSCW({ rpc: rpcUrl, address: signerAddress }); // // Check if the SCW is already deployed // const hasSCW = await isSCWDeployed({ @@ -82,12 +82,12 @@ export class UserService { // }); // If SCW is already deployed - const users = await this.userModel.find({ id: walletAddress }); + const users = await this.userModel.find({ id: signerAddress }); if (users.length > 0 && users[0].deployed) { // Update the user's providers if they already exist await this.userModel.updateOne( { - id: walletAddress, + id: signerAddress, }, { $addToSet: { providers: provider }, @@ -99,17 +99,17 @@ export class UserService { // Create the SCW deployment transaction const deploymentTransaction = await createSCW({ rpc: rpcUrl, - address: walletAddress, + address: '', // TODO: remove this not required here safe: userSafe, }); // Get the SCW address const safeAddress = await getSCW({ rpc: rpcUrl, - address: walletAddress, + address: '', // TODO: remove this not required here safe: userSafe, }); - + console.log('safeAddress:', safeAddress); // Create a mint transaction for the user's SCW // const mintTx = mint({ // contractAddress: USDC_SEPOLIA, @@ -167,7 +167,7 @@ export class UserService { // Create a new user record if they do not exist const user = await this.userModel.create({ - id: walletAddress, + id: signerAddress, wallet: safeAddress, network: 'eth-sepolia', deployed: true, diff --git a/packages/utils/src/constants/ABI/erc20.ts b/packages/utils/src/constants/ABI/erc20.ts index 8c118ce..fca0fdb 100644 --- a/packages/utils/src/constants/ABI/erc20.ts +++ b/packages/utils/src/constants/ABI/erc20.ts @@ -8,7 +8,7 @@ export const ERC20_ABI = [ "function allowance(address _owner, address _spender) public view returns (uint256 remaining)", // Authenticated Functions - "function transfer(address to, uint amount) returns (boolean)", + "function transfer(address to, uint amount) returns (bool)", "function mint(address account, uint256 amount)", "function approve(address _spender, uint256 _value) public returns (bool success)", "function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)", diff --git a/packages/utils/src/sdk/erc20/approve.ts b/packages/utils/src/sdk/erc20/approve.ts index 0dcc2c3..2d006ca 100644 --- a/packages/utils/src/sdk/erc20/approve.ts +++ b/packages/utils/src/sdk/erc20/approve.ts @@ -1,5 +1,6 @@ import { ethers, TransactionRequest } from "ethers"; import {ERC20_ABI} from "../../constants" +import { Transaction } from "@safe-global/safe-core-sdk-types"; export type ApproveParams = { contractAddress: string; @@ -8,7 +9,7 @@ export type ApproveParams = { spender: string; } -export const approve = (params: ApproveParams): TransactionRequest => { +export const approve = (params: ApproveParams): Transaction => { const { contractAddress, amount, provider, spender } = params; try { @@ -16,9 +17,10 @@ export const approve = (params: ApproveParams): TransactionRequest => { const erc20Contract = new ethers.Contract(contractAddress, ERC20_ABI, provider); const data = erc20Contract.interface.encodeFunctionData('approve', [spender, amount]); - const transactionObj: TransactionRequest = { + const transactionObj: Transaction = { to: contractAddress, - data: data + data: data, + value: '0', }; return transactionObj; } catch (error) { diff --git a/packages/utils/src/sdk/safe/safeRelay.ts b/packages/utils/src/sdk/safe/safeRelay.ts index 9916656..e92cc0a 100644 --- a/packages/utils/src/sdk/safe/safeRelay.ts +++ b/packages/utils/src/sdk/safe/safeRelay.ts @@ -1,11 +1,19 @@ import { GelatoRelayPack } from '@safe-global/relay-kit' -import Safe from '@safe-global/protocol-kit'; +import Safe, { EthSafeSignature, SigningMethod } from '@safe-global/protocol-kit'; import { + EIP712TypedDataMessage, + EIP712TypedDataTx, MetaTransactionData, MetaTransactionOptions, + OperationType, + SafeEIP712Args, SafeTransaction, + SafeTransactionData, Transaction } from '@safe-global/safe-core-sdk-types' +import EthSafeTransaction from '@safe-global/protocol-kit/dist/src/utils/transactions/SafeTransaction'; +import { EIP712_DOMAIN, adjustVInSignature, generateTypedData } from '@safe-global/protocol-kit/dist/src/utils'; +import { ethers, TypedDataDomain } from 'ethers'; export const createTransactions = async (args: { @@ -55,27 +63,102 @@ export const signSafeTransaction = async (args: { return signedSafeTransaction; }; +export const getSafeTxTypedData = async (args: { + protocolKit: Safe, // Protocol Kit instance of QW with Signer + txData: Transaction, +}): Promise<{ + domain: ethers.TypedDataDomain, + types: Record, + primaryType: string, + message: Record +}> => { + const ksafeAddress = await args.protocolKit.getAddress(); + const ksafeVersion = await args.protocolKit.getContractVersion(); + const kchainId = await args.protocolKit.getChainId(); + console.log(ksafeAddress, ksafeVersion, kchainId); + + const safeEIP712Args: SafeEIP712Args = { + safeAddress: await args.protocolKit.getAddress(), + safeVersion: await args.protocolKit.getContractVersion(), + chainId: await args.protocolKit.getChainId(), + data: { + to: args.txData.to, + value: args.txData.value, + data: args.txData.data, + operation: OperationType.Call, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: await args.protocolKit.getNonce(), + } + } + + const typedData = generateTypedData(safeEIP712Args); + + return { + types: typedData.primaryType === 'SafeMessage' + ? { SafeMessage: (typedData as EIP712TypedDataMessage).types.SafeMessage } + : { EIP712Domain: typedData.types.EIP712Domain, SafeTx: (typedData as EIP712TypedDataTx).types.SafeTx }, + primaryType: "SafeTx", + domain: typedData.domain, + message: typedData.message, + }; +}; + +export const executeSignedTypedDataRelayTx = async (args: { + safe: Safe, + gelatoRelayPack: GelatoRelayPack, + metaTransaction: MetaTransactionData, + signer: string, + signature: string, +}): Promise<{ taskId: string }> => { + const adjustedSignature = adjustVInSignature(SigningMethod.ETH_SIGN_TYPED_DATA, args.signature); + + const safeTransaction = new EthSafeTransaction({ + to: args.metaTransaction.to, + value: args.metaTransaction.value, + data: args.metaTransaction.data, + operation: args.metaTransaction.operation ?? OperationType.Call, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: await args.safe.getNonce(), + }); + + safeTransaction.addSignature(new EthSafeSignature(args.signer, adjustedSignature, false)); + + return executeRelayTransaction({ + gelatoRelayPack: args.gelatoRelayPack, + signedSafeTransaction: safeTransaction + }); +} export const executeRelayTransaction = async (args: { gelatoRelayPack: GelatoRelayPack, signedSafeTransaction: SafeTransaction, -}): Promise => { +}): Promise<{ taskId: string }> => { const { gelatoRelayPack: relayKit, signedSafeTransaction } = args; const options: MetaTransactionOptions = { isSponsored: true } + const response = await relayKit.executeTransaction({ executable: signedSafeTransaction, options }); console.log(`Relay Transaction Task ID: https://relay.gelato.digital/tasks/status/${response.taskId}`); + return response; } export const normalizeMetaTransaction = (args: { tx: Transaction }): MetaTransactionData => { - const { tx } = args; + const { tx } = args; return { to: tx.to, value: tx.value.toString(),