diff --git a/package.json b/package.json index a4f3567..8e63ef0 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "@bull-board/api": "^6.7.9", "@bull-board/express": "^6.7.9", "@bull-board/nestjs": "^6.7.9", + "@cosmjs/amino": "^0.33.1", + "@cosmjs/crypto": "^0.33.1", + "@cosmjs/proto-signing": "^0.33.1", + "@cosmjs/stargate": "^0.33.1", "@nestjs/bullmq": "^11.0.2", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.0", @@ -39,6 +43,7 @@ "@nestjs/mapped-types": "^2.0.4", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@noble/hashes": "^1.8.0", "@prisma/client": "^6.4.0", "@solana/web3.js": "^1.98.0", "arbundles": "^0.11.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1c0a36..fbdc778 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,18 @@ importers: '@bull-board/nestjs': specifier: ^6.7.9 version: 6.7.9(@bull-board/api@6.7.9(@bull-board/ui@6.7.9))(@bull-board/express@6.7.9)(@nestjs/bull-shared@11.0.2(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10))(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@cosmjs/amino': + specifier: ^0.33.1 + version: 0.33.1 + '@cosmjs/crypto': + specifier: ^0.33.1 + version: 0.33.1 + '@cosmjs/proto-signing': + specifier: ^0.33.1 + version: 0.33.1 + '@cosmjs/stargate': + specifier: ^0.33.1 + version: 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@nestjs/bullmq': specifier: ^11.0.2 version: 11.0.2(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10)(bullmq@5.41.5) @@ -41,6 +53,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.0.10(@nestjs/common@11.0.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.0.10) + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@prisma/client': specifier: ^6.4.0 version: 6.4.0(prisma@6.4.0(typescript@5.7.3))(typescript@5.7.3) @@ -418,6 +433,39 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@cosmjs/amino@0.33.1': + resolution: {integrity: sha512-WfWiBf2EbIWpwKG9AOcsIIkR717SY+JdlXM/SL/bI66BdrhniAF+/ZNis9Vo9HF6lP2UU5XrSmFA4snAvEgdrg==} + + '@cosmjs/crypto@0.33.1': + resolution: {integrity: sha512-U4kGIj/SNBzlb2FGgA0sMR0MapVgJUg8N+oIAiN5+vl4GZ3aefmoL1RDyTrFS/7HrB+M+MtHsxC0tvEu4ic/zA==} + + '@cosmjs/encoding@0.33.1': + resolution: {integrity: sha512-nuNxf29fUcQE14+1p//VVQDwd1iau5lhaW/7uMz7V2AH3GJbFJoJVaKvVyZvdFk+Cnu+s3wCqgq4gJkhRCJfKw==} + + '@cosmjs/json-rpc@0.33.1': + resolution: {integrity: sha512-T6VtWzecpmuTuMRGZWuBYHsMF/aznWCYUt/cGMWNSz7DBPipVd0w774PKpxXzpEbyt5sr61NiuLXc+Az15S/Cw==} + + '@cosmjs/math@0.33.1': + resolution: {integrity: sha512-ytGkWdKFCPiiBU5eqjHNd59djPpIsOjbr2CkNjlnI1Zmdj+HDkSoD9MUGpz9/RJvRir5IvsXqdE05x8EtoQkJA==} + + '@cosmjs/proto-signing@0.33.1': + resolution: {integrity: sha512-Sv4W+MxX+0LVnd+2rU4Fw1HRsmMwSVSYULj7pRkij3wnPwUlTVoJjmKFgKz13ooIlfzPrz/dnNjGp/xnmXChFQ==} + + '@cosmjs/socket@0.33.1': + resolution: {integrity: sha512-KzAeorten6Vn20sMiM6NNWfgc7jbyVo4Zmxev1FXa5EaoLCZy48cmT3hJxUJQvJP/lAy8wPGEjZ/u4rmF11x9A==} + + '@cosmjs/stargate@0.33.1': + resolution: {integrity: sha512-CnJ1zpSiaZgkvhk+9aTp5IPmgWn2uo+cNEBN8VuD9sD6BA0V4DMjqe251cNFLiMhkGtiE5I/WXFERbLPww3k8g==} + + '@cosmjs/stream@0.33.1': + resolution: {integrity: sha512-bMUvEENjeQPSTx+YRzVsWT1uFIdHRcf4brsc14SOoRQ/j5rOJM/aHfsf/BmdSAnYbdOQ3CMKj/8nGAQ7xUdn7w==} + + '@cosmjs/tendermint-rpc@0.33.1': + resolution: {integrity: sha512-22klDFq2MWnf//C8+rZ5/dYatr6jeGT+BmVbutXYfAK9fmODbtFcumyvB6uWaEORWfNukl8YK1OLuaWezoQvxA==} + + '@cosmjs/utils@0.33.1': + resolution: {integrity: sha512-UnLHDY6KMmC+UXf3Ufyh+onE19xzEXjT4VZ504Acmk4PXxqyvG4cCPprlKUFnGUX7f0z8Or9MAOHXBx41uHBcg==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1193,6 +1241,10 @@ packages: resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2260,6 +2312,9 @@ packages: typescript: optional: true + cosmjs-types@0.9.0: + resolution: {integrity: sha512-MN/yUe6mkJwHnCFfsNPeCfXVhyxHYW6c/xDUzrSbBycYzw++XvWDMJArXp2pLdgD6FQ8DW79vkPjeNKVrXaHeQ==} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3535,6 +3590,12 @@ packages: libphonenumber-js@1.11.20: resolution: {integrity: sha512-/ipwAMvtSZRdiQBHqW1qxqeYiBMzncOQLVA+62MWYr7N4m7Q2jqpJ0WgT7zlOEOpyLRSqrMXidbJpC0J77AaKA==} + libsodium-sumo@0.7.15: + resolution: {integrity: sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw==} + + libsodium-wrappers-sumo@0.7.15: + resolution: {integrity: sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -4078,6 +4139,9 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readonly-date@1.0.0: + resolution: {integrity: sha512-tMKIV7hlk0h4mO3JTmmVuIlJVXjKk3Sep9Bf5OH0O+758ruuVkUy2J9SttDLm91IEX/WHlXPSpxMGjPj4beMIQ==} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -4440,6 +4504,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-observable@2.0.3: + resolution: {integrity: sha512-sQV7phh2WCYAn81oAkakC5qjq2Ml0g8ozqz03wOGnx9dDlG1de6yrF+0RAzSJD8fPUow3PTSMf2SAbOGxb93BA==} + engines: {node: '>=0.10'} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -4886,6 +4954,9 @@ packages: utf-8-validate: optional: true + xstream@11.14.0: + resolution: {integrity: sha512-1bLb+kKKtKPbgTK6i/BaoAn03g47PpFstlbe1BA+y3pNS/LfvcaghS5BFf9+EE1J+KwSQsEpfJvFN5GqFtiNmw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5187,6 +5258,95 @@ snapshots: '@colors/colors@1.6.0': {} + '@cosmjs/amino@0.33.1': + dependencies: + '@cosmjs/crypto': 0.33.1 + '@cosmjs/encoding': 0.33.1 + '@cosmjs/math': 0.33.1 + '@cosmjs/utils': 0.33.1 + + '@cosmjs/crypto@0.33.1': + dependencies: + '@cosmjs/encoding': 0.33.1 + '@cosmjs/math': 0.33.1 + '@cosmjs/utils': 0.33.1 + '@noble/hashes': 1.8.0 + bn.js: 5.2.1 + elliptic: 6.6.1 + libsodium-wrappers-sumo: 0.7.15 + + '@cosmjs/encoding@0.33.1': + dependencies: + base64-js: 1.5.1 + bech32: 1.1.4 + readonly-date: 1.0.0 + + '@cosmjs/json-rpc@0.33.1': + dependencies: + '@cosmjs/stream': 0.33.1 + xstream: 11.14.0 + + '@cosmjs/math@0.33.1': + dependencies: + bn.js: 5.2.1 + + '@cosmjs/proto-signing@0.33.1': + dependencies: + '@cosmjs/amino': 0.33.1 + '@cosmjs/crypto': 0.33.1 + '@cosmjs/encoding': 0.33.1 + '@cosmjs/math': 0.33.1 + '@cosmjs/utils': 0.33.1 + cosmjs-types: 0.9.0 + + '@cosmjs/socket@0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@cosmjs/stream': 0.33.1 + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) + xstream: 11.14.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@cosmjs/stargate@0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@cosmjs/amino': 0.33.1 + '@cosmjs/encoding': 0.33.1 + '@cosmjs/math': 0.33.1 + '@cosmjs/proto-signing': 0.33.1 + '@cosmjs/stream': 0.33.1 + '@cosmjs/tendermint-rpc': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@cosmjs/utils': 0.33.1 + cosmjs-types: 0.9.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + + '@cosmjs/stream@0.33.1': + dependencies: + xstream: 11.14.0 + + '@cosmjs/tendermint-rpc@0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + dependencies: + '@cosmjs/crypto': 0.33.1 + '@cosmjs/encoding': 0.33.1 + '@cosmjs/json-rpc': 0.33.1 + '@cosmjs/math': 0.33.1 + '@cosmjs/socket': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@cosmjs/stream': 0.33.1 + '@cosmjs/utils': 0.33.1 + axios: 1.7.9 + readonly-date: 1.0.0 + xstream: 11.14.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + + '@cosmjs/utils@0.33.1': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -6119,6 +6279,8 @@ snapshots: '@noble/hashes@1.7.1': {} + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6195,7 +6357,7 @@ snapshots: dependencies: '@babel/runtime': 7.26.9 '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 + '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 agentkeepalive: 4.6.0 bigint-buffer: 1.1.5 @@ -7423,6 +7585,8 @@ snapshots: optionalDependencies: typescript: 5.7.3 + cosmjs-types@0.9.0: {} + create-jest@29.7.0(@types/node@22.13.4)(ts-node@10.9.2(@swc/core@1.10.18(@swc/helpers@0.5.15))(@types/node@22.13.4)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3 @@ -9059,6 +9223,12 @@ snapshots: libphonenumber-js@1.11.20: {} + libsodium-sumo@0.7.15: {} + + libsodium-wrappers-sumo@0.7.15: + dependencies: + libsodium-sumo: 0.7.15 + lines-and-columns@1.2.4: {} loader-runner@4.3.0: {} @@ -9552,6 +9722,8 @@ snapshots: readdirp@4.1.2: {} + readonly-date@1.0.0: {} + redis-errors@1.2.0: {} redis-info@3.1.0: @@ -9970,6 +10142,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-observable@2.0.3: {} + symbol-observable@4.0.0: {} synckit@0.9.2: @@ -10440,6 +10614,11 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 + xstream@11.14.0: + dependencies: + globalthis: 1.0.4 + symbol-observable: 2.0.3 + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/prisma/migrations/20250430202040_cosmos_support/migration.sql b/prisma/migrations/20250430202040_cosmos_support/migration.sql new file mode 100644 index 0000000..a54a2dd --- /dev/null +++ b/prisma/migrations/20250430202040_cosmos_support/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ChainType" ADD VALUE 'cosmos'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abd8a2b..007eff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -183,6 +183,7 @@ enum ChainType { evm solana arweave + cosmos } enum Network { diff --git a/src/config/index.ts b/src/config/index.ts index 1321358..5e97278 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -30,12 +30,21 @@ export default () => ({ address: process.env.EVM_ADDRESS, pk: process.env.EVM_PK, }, + cosmos: { + noble: { + address: process.env.COSMOS_NOBLE_ADDRESS, + pk: process.env.COSMOS_NOBLE_PK, + }, + }, admin: { feeConfig: { addresses: { evm: process.env.FEE_ADDRESS_EVM, solana: process.env.FEE_ADDRESS_SOLANA, arweave: process.env.FEE_ADDRESS_ARWEAVE, + cosmos: { + noble: process.env.FEE_ADDRESS_COSMOS_NOBLE, + }, }, feePercentage: process.env.FEE_PERCENTAGE, }, diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 6d17f0e..4f67129 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,21 +1,27 @@ +import { + makeSignDoc as makeSignDocAmino, + serializeSignDoc, +} from '@cosmjs/amino'; +import { Secp256k1, Secp256k1Signature } from '@cosmjs/crypto'; import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { sha256 } from "@noble/hashes/sha2" import { ChainType, User } from '@prisma/client'; import { PublicKey } from '@solana/web3.js'; import Arweave from 'arweave/node'; import * as crypto from 'crypto'; -import { ethers } from 'ethers'; +import { decodeBase64, ethers, } from 'ethers'; import * as nacl from 'tweetnacl'; import { UserService } from '../user/user.service'; import { VerifyAuthDto } from './dto/verify-auth.dto'; - @Injectable() export class AuthService { private VERIFY_MAP = { [ChainType.evm]: this.verifyEvmSignature, [ChainType.solana]: this.verifySolanaSignature, [ChainType.arweave]: this.verifyArweaveSignature, + [ChainType.cosmos]: this.cosmosVerifySignature, } constructor( @@ -27,12 +33,14 @@ export class AuthService { this.verifyEvmSignature = this.verifyEvmSignature.bind(this); this.verifySolanaSignature = this.verifySolanaSignature.bind(this); this.verifyArweaveSignature = this.verifyArweaveSignature.bind(this); + this.cosmosVerifySignature = this.cosmosVerifySignature.bind(this); this.VERIFY_MAP = { [ChainType.evm]: this.verifyEvmSignature, [ChainType.solana]: this.verifySolanaSignature, [ChainType.arweave]: this.verifyArweaveSignature, - }; + [ChainType.cosmos]: this.cosmosVerifySignature, + } } async generateNonce(user: User): Promise { @@ -199,6 +207,49 @@ export class AuthService { } } + /** +* Arweave signature verification +*/ + private async cosmosVerifySignature(user: User, signedMessage: string, signature: string, publicKey?: string) { + if (!publicKey) { + throw new BadRequestException('Invalid Cosmos public key'); + } + + const nonce = this.extractNonce(signedMessage); + if (!nonce || nonce !== user.nonce) { + throw new BadRequestException('Invalid Cosmos signature: nonce missing or mismatch'); + } + + const fixed = decodeBase64(signature); + const cSig = Secp256k1Signature.fromFixedLength(fixed); + const ADR36 = { + type: 'sign/MsgSignData', + memo: '', + accountNumber: 0, + sequence: 0, + chainId: '', + fee: { gas: '0', amount: [] }, + }; + const msgs = [{ type: ADR36.type, value: { signer: user.walletAddress, data: signedMessage } }]; + const signBytes = serializeSignDoc( + makeSignDocAmino( + msgs, + ADR36.fee, + ADR36.chainId, + ADR36.memo, + ADR36.accountNumber, + ADR36.sequence, + ), + ); + const hash = sha256(signBytes) + console.log({ hash, length: hash.length }); + const pkbytes = decodeBase64(publicKey); + const ok = await Secp256k1.verifySignature(cSig, hash, pkbytes); + if (!ok) { + throw new BadRequestException('Invalid Cosmos signature: verification failed'); + } + } + private async signToken(userId: number, expiresIn: number, payload?: T) { return await this.jwtService.signAsync( { sub: userId, ...payload }, diff --git a/src/modules/upload/upload.controller.ts b/src/modules/upload/upload.controller.ts index b242109..b75c263 100644 --- a/src/modules/upload/upload.controller.ts +++ b/src/modules/upload/upload.controller.ts @@ -67,7 +67,6 @@ export class UploadController { const verified = await this.uploadService.verifyPayment({ paymentTx: body.transactionId, chainType: token.chainType, - network: token.network, chainId: +token.chainId, senderAddress: (req as any).user.walletAddress, amount: paymentTransaction.amountInSubUnits, @@ -160,7 +159,6 @@ export class UploadController { verified = await this.uploadService.verifyPayment({ paymentTx: txnHash, chainType: token.chainType, - network: token.network, chainId: +token.chainId, senderAddress: (req as any).user.walletAddress, amount: paymentTransaction.amountInSubUnits, diff --git a/src/modules/upload/upload.service.ts b/src/modules/upload/upload.service.ts index c2636f2..24c8558 100644 --- a/src/modules/upload/upload.service.ts +++ b/src/modules/upload/upload.service.ts @@ -1,3 +1,4 @@ +import { IndexedTx, StargateClient } from '@cosmjs/stargate'; import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ChainType, Network, ReceiptStatus, TokenTicker, TransactionStatus, UploadStatus, } from '@prisma/client'; @@ -57,7 +58,12 @@ export class UploadService { const costInToken = await this.priceFeedService.convertToTokenAmount(costInUSD, estimatesDto.tokenTicker, token.decimals); const ticker = estimatesDto.tokenTicker.toLowerCase(); - const chainType = estimatesDto.chainType.toLowerCase(); + let chainType = estimatesDto.chainType.toLowerCase(); + + if (chainType === ChainType.cosmos) { + chainType = 'cosmos.noble'; + } + const payAddress = this.configService.get(`${chainType}.address`); if (!payAddress) { @@ -231,19 +237,21 @@ export class UploadService { chainId, senderAddress, amount, - network }: { tokenAddress: string; paymentTx: string; chainType: ChainType; - network: Network; chainId: number; senderAddress: string; amount: string; }) { - const provider = Web3Provider.getProvider(chainType, network, chainId); - const systemAddress = this.configService.get(`${chainType}.address`); + const provider = await Web3Provider.getProvider(chainType, chainId); + let _chainType = chainType.toLowerCase(); + if (chainType === ChainType.cosmos) { + _chainType = 'cosmos.noble'; + } + const systemAddress = this.configService.get(`${_chainType}.address`); if (!systemAddress) { throw new BadRequestException(`System address not found for chain type ${chainType}`); @@ -399,6 +407,51 @@ export class UploadService { return valid; } + if (chainType === ChainType.cosmos) { + // 1) connect to the chain + const client = (await Web3Provider.getProvider( + chainType, + chainId + )) as StargateClient; + + // 3) fetch the transaction + let txResponse: IndexedTx | null; + try { + txResponse = await client.getTx(paymentTx); + } catch (err: any) { + throw new BadRequestException(`Cosmos tx not found: ${paymentTx}`); + } + if (!txResponse) { + throw new BadRequestException(`Cosmos tx not found: ${paymentTx}`); + } + if (txResponse.code !== 0) { + throw new BadRequestException('Transaction failed on-chain'); + } + + // 4) scan for a transfer event matching sender→system for the correct denom+amount + const denom = tokenAddress; + for (const evt of txResponse.events) { + console.log(JSON.stringify(evt, null, 2)) + if (evt.type !== 'transfer') continue; + const attrs = evt.attributes.reduce>((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}); + + if ( + attrs.sender === senderAddress && + attrs.recipient === systemAddress && + attrs.amount?.endsWith(denom) + ) { + const valueOnly = attrs.amount.slice(0, -denom.length); + console.log({ valueOnly, amount }) + if (valueOnly === amount) { + return true; + } + } + } + } + return false; } @@ -533,8 +586,12 @@ export class UploadService { throw new BadRequestException("Token not found"); } - const systemAddress = this.configService.get(`${token.chainType}.address`); - const systemPrivateKey = this.configService.get(`${token.chainType}.pk`); + let _chainType = token.chainType.toLowerCase(); + if (token.chainType === ChainType.cosmos) { + _chainType = 'cosmos.noble'; + } + const systemAddress = this.configService.get(`${_chainType}.address`); + const systemPrivateKey = this.configService.get(`${_chainType}.pk`); const feeAddress = this.configService.get(`admin.feeConfig.addresses.${token.chainType}`); if (!systemAddress || !feeAddress) { @@ -547,11 +604,11 @@ export class UploadService { const ERC20_ABI = [ "function transfer(address recipient, uint256 amount) external returns (bool)" ]; - const signer = Web3Provider.getSigner(token.chainType, token.chainId, systemPrivateKey); - const tokenContract = new Contract(token.address, ERC20_ABI, signer); + const signer = await Web3Provider.getSigner(token.chainType, token.chainId, systemPrivateKey); + const tokenContract = new Contract(token.address, ERC20_ABI, signer as any); const feeAmountBN = BigInt(feeAmount); - const contractSigner = tokenContract.connect(signer) as any + const contractSigner = tokenContract.connect(signer as any) as any const tx = await contractSigner.transfer(feeAddress, feeAmountBN) @@ -595,6 +652,12 @@ export class UploadService { data: { status: TransactionStatus.SUCCEEDED, transactionHash: txn.id } }); } + + if (ChainType.cosmos === token.chainType) { + // + console.log("not implemented") + // throw new BadRequestException("Cosmos fee debit not implemented"); + } } private async validateToken(chainType: ChainType, tokenTicker: TokenTicker, chainId: number, network: Network) { diff --git a/src/modules/upload/utils/Web3Provider.util.ts b/src/modules/upload/utils/Web3Provider.util.ts index 69fcc38..f909324 100644 --- a/src/modules/upload/utils/Web3Provider.util.ts +++ b/src/modules/upload/utils/Web3Provider.util.ts @@ -1,87 +1,128 @@ -import { BadRequestException } from '@nestjs/common'; -import { ChainType, Network } from '@prisma/client'; -import { Connection } from '@solana/web3.js'; -import Arweave from "arweave/node"; -import { ethers, Provider, Signer } from 'ethers'; +import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing' +import { + SigningStargateClient, + StargateClient, +} from '@cosmjs/stargate' +import { BadRequestException } from '@nestjs/common' +import { ChainType } from '@prisma/client' +import { Connection } from '@solana/web3.js' +import Arweave from 'arweave/node' +import { ethers, Provider, Signer } from 'ethers' - -export const evmChainIdMap = { - 1: "eth-mainnet", - 8453: "base-mainnet", - 84532: "base-sepolia", +/** + * EVM: map from numeric chainId → Alchemy prefix + */ +export const evmChainIdMap: Record = { + 1: 'eth-mainnet', + 8453: 'base-mainnet', + 84532: 'base-sepolia', } /** - * Web3Provider class acts as a factory to build web3 clients. - * It supports both EVM and Solana based on the provided chain type and network. + * map from your numeric chainId → { chainId, rpc endpoint, bech32 prefix } */ +export const cosmosChainIdMap: Record< + number, + { chainId: string; rpc: string; prefix: string } +> = { + 1: { + chainId: 'noble-1', + rpc: process.env.NOBLE_MAINNET_RPC_URL ?? + 'https://noble-rpc.polkachu.com', + prefix: process.env.NOBLE_MAINNET_PREFIX ?? 'noble', + }, + 2: { + chainId: 'noble-testnet-1', + rpc: process.env.NOBLE_TESTNET_RPC_URL ?? + 'https://rpc.testnet.noble.strange.love', + prefix: process.env.NOBLE_TESTNET_PREFIX ?? 'noble-testnet', + }, +} + export class Web3Provider { - /** - * Returns an ethers.js provider for EVM networks. - * @param network - Network type (MAINNET or TESTNET). - */ + /** EVM */ public static getEvmProvider(chainId: number): Provider { - const rpcPrefix = evmChainIdMap[chainId]; - - if (!rpcPrefix) { - throw new BadRequestException(`Unsupported chainId: ${chainId}`); + const prefix = evmChainIdMap[chainId] + if (!prefix) { + throw new BadRequestException(`Unsupported EVM chainId: ${chainId}`) } + const url = `https://${prefix}.g.alchemy.com/v2/${process.env.INFURA_API_KEY}` + return new ethers.JsonRpcProvider(url) + } + + /** Solana (unchanged—still keyed off chainId if you want) */ + public static getSolanaConnection(chainId: number): Connection { + // you could introduce a solanaChainMap here in the same way + const endpoint = + chainId === 1 + ? `https://solana-mainnet.g.alchemy.com/v2/${process.env.INFURA_API_KEY}` + : `https://solana-devnet.g.alchemy.com/v2/${process.env.INFURA_API_KEY}` - const rpcUrl: string = `https://${rpcPrefix}.g.alchemy.com/v2/${process.env.INFURA_API_KEY}`; - return new ethers.JsonRpcProvider(rpcUrl); + return new Connection(endpoint, 'confirmed') } /** - * Returns a Solana connection using @solana/web3.js. - * @param network - Network type (MAINNET or TESTNET). + * Universal provider */ - public static getSolanaConnection(network: Network): Connection { - let prefix: string; - if (network === Network.mainnet) { - // Use an environment variable or fallback to Solana mainnet-beta endpoint. - prefix = "solana-mainnet"; - } else { - // Use an environment variable or fallback to Solana testnet endpoint. - prefix = "solana-devnet"; - } + public static async getProvider( + chainType: ChainType, + chainId: number + ): Promise { + switch (chainType) { + case ChainType.evm: + return this.getEvmProvider(chainId) - if (!prefix) { - throw new BadRequestException("Unsupported network for solana connection"); - } + case ChainType.solana: + return this.getSolanaConnection(chainId) + + case ChainType.arweave: + return Arweave.init({ host: 'arweave.net', port: 443, protocol: 'https' }) + + case ChainType.cosmos: { + const info = cosmosChainIdMap[chainId] + if (!info) { + throw new BadRequestException(`Unsupported Cosmos chainId: ${chainId}`) + } + + return StargateClient.connect(info.rpc) + } - const endpoint: string = `https://${prefix}.g.alchemy.com/v2/${process.env.INFURA_API_KEY}`; - return new Connection(endpoint, 'confirmed'); + default: + throw new BadRequestException(`Unsupported chain type: ${chainType}`) + } } /** - * Returns a web3 client based on the chain type and network. - * @param chainType - The type of blockchain (EVM or SOLANA). - * @param network - The network to connect to (MAINNET or TESTNET). + * Universal signer */ - public static getProvider(chainType: ChainType, network: Network, chainId: number): Provider | Connection | Arweave { - if (chainType === ChainType.evm) { - return this.getEvmProvider(chainId); - } else if (chainType === ChainType.solana) { - return this.getSolanaConnection(network); - } else if (chainType === ChainType.arweave) { - return Arweave.init({ - host: 'arweave.net', - port: 443, - protocol: 'https', - }); - } else { - throw new Error('Unsupported chain type'); - } - } + public static async getSigner( + chainType: ChainType, + chainId: number, + privateKey: string + ): Promise { + switch (chainType) { + case ChainType.evm: { + const provider = this.getEvmProvider(chainId) + return new ethers.Wallet(privateKey, provider) + } + + case ChainType.solana: + throw new BadRequestException('Solana signer not implemented') + + case ChainType.cosmos: { + const info = cosmosChainIdMap[chainId] + if (!info) { + throw new BadRequestException(`Unsupported Cosmos chainId: ${chainId}`) + } + // here we treat privateKey as a mnemonic + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(privateKey, { + prefix: info.prefix, + }) + return SigningStargateClient.connectWithSigner(info.rpc, wallet) + } - public static getSigner(chainType: ChainType, chainId: number, privateKey: string): Signer { - if (chainType === ChainType.evm) { - return new ethers.Wallet(privateKey, this.getEvmProvider(chainId)); - } else if (chainType === ChainType.solana) { - // TODO: Implement solana signer - throw new BadRequestException("Solana signer not implemented"); - } else { - throw new Error('Unsupported chain type'); + default: + throw new BadRequestException(`Unsupported chain type: ${chainType}`) } } }