From 68e40c31082fb90eecc22dcce7c4c18b23c34e4a Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:28:12 +0100 Subject: [PATCH] fix: ugly temp thor/maya chain fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .env | 6 +- .env.development | 6 +- .../mayachain/MayachainChainAdapter.ts | 110 ++- .../mayachain/SecondClassMayachainAdapter.ts | 637 +++++++++++++++++ .../src/cosmossdk/mayachain/index.ts | 1 + .../thorchain/SecondClassThorchainAdapter.ts | 664 ++++++++++++++++++ .../thorchain/ThorchainChainAdapter.ts | 108 ++- .../src/cosmossdk/thorchain/index.ts | 1 + .../swappers/MayachainSwapper/endpoints.ts | 3 +- .../swappers/ThorchainSwapper/endpoints.ts | 3 +- .../src/thorchain-utils/checkTradeStatus.ts | 24 +- src/constants/chains.ts | 2 + .../useSendActionSubscriber.tsx | 14 + src/lib/utils/mayachain.ts | 54 ++ src/lib/utils/thorchain/index.ts | 34 + src/plugins/mayachain/index.tsx | 16 +- src/plugins/thorchain/index.tsx | 23 +- 17 files changed, 1651 insertions(+), 55 deletions(-) create mode 100644 packages/chain-adapters/src/cosmossdk/mayachain/SecondClassMayachainAdapter.ts create mode 100644 packages/chain-adapters/src/cosmossdk/thorchain/SecondClassThorchainAdapter.ts create mode 100644 src/lib/utils/mayachain.ts diff --git a/.env b/.env index 95c3cb8c5bd..316eb96e130 100644 --- a/.env +++ b/.env @@ -157,7 +157,8 @@ VITE_MONAD_NODE_URL=https://rpc.monad.xyz VITE_PLASMA_NODE_URL=https://rpc.plasma.to # VITE_THORCHAIN_NODE_URL=https://api.thorchain.shapeshift.com/lcd VITE_THORCHAIN_NODE_URL=https://thornode.ninerealms.com -VITE_MAYACHAIN_NODE_URL=https://api.mayachain.shapeshift.com/lcd +# VITE_MAYACHAIN_NODE_URL=https://api.mayachain.shapeshift.com/lcd +VITE_MAYACHAIN_NODE_URL=https://mayanode.mayachain.info VITE_SOLANA_NODE_URL=https://api.solana.shapeshift.com/api/v1/jsonrpc VITE_TRON_NODE_URL=https://api.trongrid.io VITE_ALCHEMY_POLYGON_URL=https://polygon-mainnet.g.alchemy.com/v2/anoTMcIc2hbPUxri37h4DeuUwg2p5_xZ @@ -165,7 +166,8 @@ VITE_ALCHEMY_POLYGON_URL=https://polygon-mainnet.g.alchemy.com/v2/anoTMcIc2hbPUx # midgard # VITE_THORCHAIN_MIDGARD_URL=https://api.thorchain.shapeshift.com/midgard/v2 VITE_THORCHAIN_MIDGARD_URL=https://midgard.ninerealms.com/v2 -VITE_MAYACHAIN_MIDGARD_URL=https://api.mayachain.shapeshift.com/midgard/v2 +# VITE_MAYACHAIN_MIDGARD_URL=https://api.mayachain.shapeshift.com/midgard/v2 +VITE_MAYACHAIN_MIDGARD_URL=https://midgard.mayachain.info/v2 # foxy apr VITE_TOKEMAK_STATS_URL=https://stats.tokemaklabs.com/ diff --git a/.env.development b/.env.development index b9cf13a59d2..99401bfa55b 100644 --- a/.env.development +++ b/.env.development @@ -67,14 +67,16 @@ VITE_HYPEREVM_NODE_URL=https://rpc.hyperliquid.xyz/evm # Swap me back to SS as-needed # VITE_THORCHAIN_NODE_URL=https://dev-api.thorchain.shapeshift.com/lcd VITE_THORCHAIN_NODE_URL=https://thornode.ninerealms.com -VITE_MAYACHAIN_NODE_URL=https://dev-api.mayachain.shapeshift.com/lcd +# VITE_MAYACHAIN_NODE_URL=https://dev-api.mayachain.shapeshift.com/lcd +VITE_MAYACHAIN_NODE_URL=https://mayanode.mayachain.info VITE_SOLANA_NODE_URL=https://dev-api.solana.shapeshift.com/api/v1/jsonrpc VITE_TRON_NODE_URL=https://api.trongrid.io # midgard # VITE_THORCHAIN_MIDGARD_URL=https://dev-api.thorchain.shapeshift.com/midgard/v2 VITE_THORCHAIN_MIDGARD_URL=https://midgard.ninerealms.com/v2 -VITE_MAYACHAIN_MIDGARD_URL=https://dev-api.mayachain.shapeshift.com/midgard/v2 +# VITE_MAYACHAIN_MIDGARD_URL=https://dev-api.mayachain.shapeshift.com/midgard/v2 +VITE_MAYACHAIN_MIDGARD_URL=https://midgard.mayachain.info/v2 NODE_ENV=development diff --git a/packages/chain-adapters/src/cosmossdk/mayachain/MayachainChainAdapter.ts b/packages/chain-adapters/src/cosmossdk/mayachain/MayachainChainAdapter.ts index c6b59c4e930..45fa97dd5b4 100644 --- a/packages/chain-adapters/src/cosmossdk/mayachain/MayachainChainAdapter.ts +++ b/packages/chain-adapters/src/cosmossdk/mayachain/MayachainChainAdapter.ts @@ -1,5 +1,9 @@ import type { AssetId } from '@shapeshiftoss/caip' -import { ASSET_REFERENCE, mayachainAssetId } from '@shapeshiftoss/caip' +import { + ASSET_REFERENCE, + generateAssetIdFromCosmosSdkDenom, + mayachainAssetId, +} from '@shapeshiftoss/caip' import type { HDWallet, MayachainSignTx, MayachainWallet } from '@shapeshiftoss/hdwallet-core' import { supportsMayachain } from '@shapeshiftoss/hdwallet-core' import type { RootBip44Params } from '@shapeshiftoss/types' @@ -8,6 +12,8 @@ import * as unchained from '@shapeshiftoss/unchained-client' import { ChainAdapterError, ErrorHandler } from '../../error/ErrorHandler' import type { + Account, + BroadcastTransactionInput, BuildDepositTxInput, BuildSendApiTxInput, BuildSendTxInput, @@ -34,6 +40,7 @@ const DEFAULT_CHAIN_ID = KnownChainIds.MayachainMainnet export interface ChainAdapterArgs extends BaseChainAdapterArgs { midgardUrl: string + nodeUrl: string } export class ChainAdapter extends CosmosSdkBaseAdapter { @@ -43,6 +50,8 @@ export class ChainAdapter extends CosmosSdkBaseAdapter> { + try { + const [authRes, balanceRes] = await Promise.all([ + fetch(`${this.nodeUrl}/cosmos/auth/v1beta1/accounts/${pubkey}`), + fetch(`${this.nodeUrl}/cosmos/bank/v1beta1/balances/${pubkey}`), + ]) + + if (!authRes.ok) { + throw new Error(`Failed to fetch account: ${authRes.status} ${authRes.statusText}`) + } + if (!balanceRes.ok) { + throw new Error(`Failed to fetch balances: ${balanceRes.status} ${balanceRes.statusText}`) + } + + const authData = (await authRes.json()) as { + account: { account_number: string; sequence: string; pub_key?: { key: string } } + } + const balanceData = (await balanceRes.json()) as { + balances: { denom: string; amount: string }[] + } + + const assets = balanceData.balances.reduce<{ amount: string; assetId: AssetId }[]>( + (acc, b) => { + if (b.denom === this.denom) return acc + try { + acc.push({ + amount: b.amount, + assetId: generateAssetIdFromCosmosSdkDenom(b.denom), + }) + } catch {} + return acc + }, + [], + ) + + const nativeBalance = balanceData.balances.find(b => b.denom === this.denom)?.amount ?? '0' + + return { + balance: nativeBalance, + pubkey, + chainId: this.chainId, + assetId: this.assetId, + chain: this.getType(), + chainSpecific: { + accountNumber: authData.account.account_number, + sequence: authData.account.sequence, + assets, + delegations: [], + redelegations: [], + undelegations: [], + rewards: [], + }, + } as Account + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getAccount', + options: { pubkey }, + }) + } + } + + async broadcastTransaction({ + senderAddress, + receiverAddress, + hex, + }: BroadcastTransactionInput): Promise { + try { + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + const response = await fetch(`${this.nodeUrl}/cosmos/tx/v1beta1/txs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tx_bytes: hex, + mode: 'BROADCAST_MODE_SYNC', + }), + }) + + const data = (await response.json()) as { + tx_response?: { code: number; txhash: string; raw_log?: string } + } + + if (data.tx_response?.code !== 0) { + throw new Error(data.tx_response?.raw_log || 'Broadcast failed') + } + + return data.tx_response.txhash + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.broadcastTransaction', + }) + } + } } diff --git a/packages/chain-adapters/src/cosmossdk/mayachain/SecondClassMayachainAdapter.ts b/packages/chain-adapters/src/cosmossdk/mayachain/SecondClassMayachainAdapter.ts new file mode 100644 index 00000000000..6245cc811f5 --- /dev/null +++ b/packages/chain-adapters/src/cosmossdk/mayachain/SecondClassMayachainAdapter.ts @@ -0,0 +1,637 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { + ASSET_REFERENCE, + fromChainId, + generateAssetIdFromCosmosSdkDenom, + mayachainAssetId, +} from '@shapeshiftoss/caip' +import type { HDWallet, MayachainSignTx, MayachainWallet } from '@shapeshiftoss/hdwallet-core' +import { supportsMayachain } from '@shapeshiftoss/hdwallet-core' +import type { Bip44Params, RootBip44Params } from '@shapeshiftoss/types' +import { KnownChainIds } from '@shapeshiftoss/types' +import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' +import { bech32 } from 'bech32' + +import type { ChainAdapter as IChainAdapter } from '../../api' +import { ChainAdapterError, ErrorHandler } from '../../error/ErrorHandler' +import type { + Account, + BroadcastTransactionInput, + BuildDepositTxInput, + BuildSendApiTxInput, + BuildSendTxInput, + FeeDataEstimate, + GetAddressInput, + GetBip44ParamsInput, + GetFeeDataInput, + SignAndBroadcastTransactionInput, + SignTxInput, + SubscribeError, + SubscribeTxsInput, + Transaction, + TxHistoryInput, + TxHistoryResponse, + ValidAddressResult, +} from '../../types' +import { ChainAdapterDisplayName, CONTRACT_INTERACTION, ValidAddressResultType } from '../../types' +import { toAddressNList, verifyLedgerAppOpen } from '../../utils' +import { bnOrZero } from '../../utils/bignumber' +import { assertAddressNotSanctioned } from '../../utils/validateAddress' +import type { + BuildTransactionInput, + CosmosSDKToken, + MayachainMsgDeposit, + MayachainMsgSend, +} from '../types' +import { MayachainMessageType } from '../types' + +const NATIVE_FEE = '2000000000' + +export type SecondClassMayachainAdapterArgs = { + nodeUrl: string + midgardUrl: string +} + +export class SecondClassMayachainAdapter implements IChainAdapter { + public static readonly rootBip44Params: RootBip44Params = { + purpose: 44, + coinType: Number(ASSET_REFERENCE.Mayachain), + accountNumber: 0, + } + + protected readonly nodeUrl: string + protected readonly midgardUrl: string + protected readonly assetId: AssetId = mayachainAssetId + protected readonly chainId = KnownChainIds.MayachainMainnet + protected readonly denom = 'cacao' + + constructor(args: SecondClassMayachainAdapterArgs) { + this.nodeUrl = args.nodeUrl + this.midgardUrl = args.midgardUrl + } + + private assertSupportsChain(wallet: HDWallet): asserts wallet is MayachainWallet { + if (!supportsMayachain(wallet)) { + throw new ChainAdapterError(`wallet does not support: ${this.getDisplayName()}`, { + translation: 'chainAdapters.errors.unsupportedChain', + options: { chain: this.getDisplayName() }, + }) + } + } + + getDisplayName() { + return ChainAdapterDisplayName.Mayachain + } + + getName() { + const enumIndex = Object.values(ChainAdapterDisplayName).indexOf( + ChainAdapterDisplayName.Mayachain, + ) + return Object.keys(ChainAdapterDisplayName)[enumIndex] + } + + getType(): KnownChainIds.MayachainMainnet { + return KnownChainIds.MayachainMainnet + } + + getChainId() { + return this.chainId + } + + getFeeAssetId(): AssetId { + return this.assetId + } + + getBip44Params({ accountNumber }: GetBip44ParamsInput): Bip44Params { + if (accountNumber < 0) throw new Error('accountNumber must be >= 0') + return { + ...SecondClassMayachainAdapter.rootBip44Params, + accountNumber, + isChange: false, + addressIndex: 0, + } + } + + async getAddress(input: GetAddressInput): Promise { + try { + const { wallet, accountNumber, pubKey, showOnDevice = false } = input + + if (pubKey) return pubKey + + if (!wallet) throw new Error('wallet is required') + this.assertSupportsChain(wallet) + await verifyLedgerAppOpen(this.chainId, wallet) + + const bip44Params = this.getBip44Params({ accountNumber }) + const address = await wallet.mayachainGetAddress({ + addressNList: toAddressNList(bip44Params), + showDisplay: showOnDevice, + }) + + if (!address) throw new Error('error getting address from wallet') + + return address + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getAddress', + }) + } + } + + // eslint-disable-next-line require-await + async validateAddress(address: string): Promise { + const MAYACHAIN_PREFIX = 'maya' + + try { + const decoded = bech32.decode(address) + if (decoded.prefix !== MAYACHAIN_PREFIX) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + + return { valid: true, result: ValidAddressResultType.Valid } + } catch { + return { valid: false, result: ValidAddressResultType.Invalid } + } + } + + async getAccount(pubkey: string): Promise> { + try { + const [authRes, balanceRes] = await Promise.all([ + fetch(`${this.nodeUrl}/cosmos/auth/v1beta1/accounts/${pubkey}`), + fetch(`${this.nodeUrl}/cosmos/bank/v1beta1/balances/${pubkey}`), + ]) + + if (!authRes.ok) { + throw new Error(`Failed to fetch account: ${authRes.status} ${authRes.statusText}`) + } + if (!balanceRes.ok) { + throw new Error(`Failed to fetch balances: ${balanceRes.status} ${balanceRes.statusText}`) + } + + const authData = (await authRes.json()) as { + account: { account_number: string; sequence: string; pub_key?: { key: string } } + } + const balanceData = (await balanceRes.json()) as { + balances: { denom: string; amount: string }[] + } + + const assets = balanceData.balances.reduce((acc, b) => { + if (b.denom === this.denom) return acc + try { + acc.push({ + amount: b.amount, + assetId: generateAssetIdFromCosmosSdkDenom(b.denom), + }) + } catch {} + return acc + }, []) + + const nativeBalance = balanceData.balances.find(b => b.denom === this.denom)?.amount ?? '0' + + return { + balance: nativeBalance, + pubkey, + chainId: this.chainId, + assetId: this.assetId, + chain: this.getType(), + chainSpecific: { + accountNumber: authData.account.account_number, + sequence: authData.account.sequence, + assets, + delegations: [], + redelegations: [], + undelegations: [], + rewards: [], + }, + } as Account + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getAccount', + options: { pubkey }, + }) + } + } + + async broadcastTransaction({ + senderAddress, + receiverAddress, + hex, + }: BroadcastTransactionInput): Promise { + try { + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + const response = await fetch(`${this.nodeUrl}/cosmos/tx/v1beta1/txs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tx_bytes: hex, + mode: 'BROADCAST_MODE_SYNC', + }), + }) + + const data = (await response.json()) as { + tx_response?: { code: number; txhash: string; raw_log?: string } + } + + if (data.tx_response?.code !== 0) { + throw new Error(data.tx_response?.raw_log || 'Broadcast failed') + } + + return data.tx_response.txhash + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.broadcastTransaction', + }) + } + } + + async signTransaction(signTxInput: SignTxInput): Promise { + try { + const { txToSign, wallet } = signTxInput + + if (!wallet) throw new Error('wallet is required') + this.assertSupportsChain(wallet) + await verifyLedgerAppOpen(this.chainId, wallet) + + const signedTx = await wallet.mayachainSignTx(txToSign) + + if (!signedTx?.serialized) throw new Error('error signing tx') + + return signedTx.serialized + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.signTransaction', + }) + } + } + + async signAndBroadcastTransaction({ + senderAddress, + receiverAddress, + signTxInput, + }: SignAndBroadcastTransactionInput): Promise { + try { + const { wallet } = signTxInput + + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + if (!wallet) throw new Error('wallet is required') + this.assertSupportsChain(wallet) + + const hex = await this.signTransaction(signTxInput) + const txHash = await this.broadcastTransaction({ senderAddress, receiverAddress, hex }) + + return txHash + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.signAndBroadcastTransaction', + }) + } + } + + protected getAmount({ + account, + value, + fee, + sendMax, + }: { + account: Account + value: string + fee: string + sendMax?: boolean + }) { + if (!sendMax) return value + + const availableBalance = bnOrZero(account.balance).minus(fee) + + if (!availableBalance.isFinite() || availableBalance.lte(0)) { + throw new Error(`not enough balance to send: ${availableBalance.toString()}`) + } + + return availableBalance.toString() + } + + protected buildTransaction(input: BuildTransactionInput): { + txToSign: MayachainSignTx + } { + const { account, accountNumber, msg, memo = '', chainSpecific } = input + const { gas, fee } = chainSpecific + + const bip44Params = this.getBip44Params({ accountNumber }) + + const unsignedTx = { + fee: { amount: [{ amount: bnOrZero(fee).toString(), denom: this.denom }], gas }, + msg: [msg], + signatures: [], + memo, + } + + const txToSign = { + addressNList: toAddressNList(bip44Params), + tx: unsignedTx, + chain_id: fromChainId(this.getType()).chainReference, + account_number: account.chainSpecific.accountNumber, + sequence: account.chainSpecific.sequence, + } as unknown as MayachainSignTx + + return { txToSign } + } + + async buildSendApiTransaction( + input: BuildSendApiTxInput, + ): Promise<{ txToSign: MayachainSignTx }> { + try { + const { sendMax, to, value, from } = input + + const account = await this.getAccount(from) + const amount = this.getAmount({ account, value, fee: NATIVE_FEE, sendMax }) + + const msg: MayachainMsgSend = { + type: MayachainMessageType.MsgSend, + value: { + amount: [{ amount, denom: this.denom }], + from_address: from, + to_address: to, + }, + } + + const tx = Object.assign(input, { + account, + msg, + chainSpecific: { ...input.chainSpecific, fee: '0' }, + }) + + return this.buildTransaction(tx) + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.buildTransaction', + }) + } + } + + async buildSendTransaction( + input: BuildSendTxInput, + ): Promise<{ txToSign: MayachainSignTx }> { + try { + const { accountNumber, wallet } = input + + const from = await this.getAddress({ accountNumber, wallet }) + const tx = await this.buildSendApiTransaction({ ...input, from }) + + return tx + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.buildTransaction', + }) + } + } + + async buildDepositTransaction( + input: BuildDepositTxInput, + ): Promise<{ txToSign: MayachainSignTx }> { + try { + const { from, value, memo } = input + + const account = await this.getAccount(from) + + const msg: MayachainMsgDeposit = { + type: MayachainMessageType.MsgDeposit, + value: { + coins: [{ asset: 'MAYA.CACAO', amount: bnOrZero(value).toString() }], + memo, + signer: from, + }, + } + + const tx = Object.assign(input, { + account, + msg, + chainSpecific: { ...input.chainSpecific, fee: '0' }, + }) + + return this.buildTransaction(tx) + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.buildTransaction', + }) + } + } + + // eslint-disable-next-line require-await + async getFeeData( + _: Partial>, + ): Promise> { + return { + fast: { txFee: NATIVE_FEE, chainSpecific: { gasLimit: '500000' } }, + average: { txFee: NATIVE_FEE, chainSpecific: { gasLimit: '500000' } }, + slow: { txFee: NATIVE_FEE, chainSpecific: { gasLimit: '500000' } }, + } + } + + getTxHistory(_input: TxHistoryInput): Promise { + return Promise.resolve({ + cursor: '', + pubkey: _input.pubkey, + transactions: [], + txIds: [], + }) + } + + subscribeTxs( + _input: SubscribeTxsInput, + _onMessage: (msg: Transaction) => void, + _onError: (err: SubscribeError) => void, + ): Promise { + return Promise.resolve() + } + + unsubscribeTxs(_input?: SubscribeTxsInput): void { + return + } + + closeTxs(): void { + return + } + + async parseTx(txHash: unknown, pubkey: string): Promise { + const hash = txHash as string + + const getAssetIdFromDenom = (denom: string): AssetId => { + const normalizedDenom = denom.toLowerCase().replace('maya.', '') + if (normalizedDenom === this.denom) return this.assetId + try { + return generateAssetIdFromCosmosSdkDenom(normalizedDenom) + } catch { + return this.assetId + } + } + + const response = await fetch(`${this.nodeUrl}/cosmos/tx/v1beta1/txs/${hash}`) + + if (response.ok) { + const data = (await response.json()) as { + tx_response: { + txhash: string + height: string + code: number + timestamp: string + tx: { + body: { + messages: { + '@type': string + from_address?: string + to_address?: string + amount?: { denom: string; amount: string }[] + coins?: { asset: string; amount: string }[] + memo?: string + signer?: string + }[] + memo?: string + } + } + } + } + + const txResponse = data.tx_response + const messages = txResponse.tx.body.messages + const status = txResponse.code === 0 ? TxStatus.Confirmed : TxStatus.Failed + const blockHeight = parseInt(txResponse.height, 10) + const blockTime = Math.floor(new Date(txResponse.timestamp).getTime() / 1000) + + const transfers: Transaction['transfers'] = [] + + for (const msg of messages) { + if (msg['@type'] === '/types.MsgSend' && msg.from_address && msg.to_address && msg.amount) { + for (const coin of msg.amount) { + const isSend = msg.from_address.toLowerCase() === pubkey.toLowerCase() + const isReceive = msg.to_address.toLowerCase() === pubkey.toLowerCase() + + if (isSend || isReceive) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.denom), + from: [msg.from_address], + to: [msg.to_address], + type: isSend ? TransferType.Send : TransferType.Receive, + value: coin.amount, + }) + } + } + } else if (msg['@type'] === '/types.MsgDeposit' && msg.signer && msg.coins) { + for (const coin of msg.coins) { + const isSigner = msg.signer.toLowerCase() === pubkey.toLowerCase() + if (isSigner) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.asset), + from: [msg.signer], + to: [msg.signer], + type: TransferType.Send, + value: coin.amount, + }) + } + } + } + } + + return { + txid: txResponse.txhash, + blockHash: '', + blockHeight, + blockTime, + confirmations: 1, + status, + chainId: this.chainId, + pubkey, + transfers, + data: txResponse.tx.body.memo + ? { parser: 'mayachain' as const, memo: txResponse.tx.body.memo } + : undefined, + } + } + + // Cosmos endpoint doesn't work for some txs, fall back to midgard + const midgardResponse = await fetch(`${this.midgardUrl}/actions?txid=${hash}`) + + if (!midgardResponse.ok) { + throw new Error( + `Failed to fetch transaction: ${midgardResponse.status} ${midgardResponse.statusText}`, + ) + } + + const midgardData = (await midgardResponse.json()) as { + actions: { + date: string + height: string + status: string + type: string + in: { address: string; coins: { amount: string; asset: string }[]; txID: string }[] + out: { address: string; coins: { amount: string; asset: string }[]; txID: string }[] + metadata?: { send?: { memo?: string }; swap?: { memo?: string } } + }[] + } + + if (!midgardData.actions?.length) { + throw new Error(`Transaction not found: ${hash}`) + } + + const action = midgardData.actions[0] + const status = action.status === 'success' ? TxStatus.Confirmed : TxStatus.Failed + const blockHeight = parseInt(action.height, 10) + const blockTime = Math.floor(parseInt(action.date, 10) / 1_000_000_000) + + const transfers: Transaction['transfers'] = [] + + for (const input of action.in) { + for (const coin of input.coins) { + const isSend = input.address.toLowerCase() === pubkey.toLowerCase() + if (isSend) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.asset), + from: [input.address], + to: [action.out[0]?.address ?? input.address], + type: TransferType.Send, + value: coin.amount, + }) + } + } + } + + for (const output of action.out) { + for (const coin of output.coins) { + const isReceive = output.address.toLowerCase() === pubkey.toLowerCase() + const alreadyAdded = transfers.some( + t => t.type === TransferType.Send && t.to.includes(output.address), + ) + if (isReceive && !alreadyAdded) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.asset), + from: [action.in[0]?.address ?? output.address], + to: [output.address], + type: TransferType.Receive, + value: coin.amount, + }) + } + } + } + + const memo = action.metadata?.send?.memo ?? action.metadata?.swap?.memo + + return { + txid: hash, + blockHash: '', + blockHeight, + blockTime, + confirmations: 1, + status, + chainId: this.chainId, + pubkey, + transfers, + data: memo ? { parser: 'mayachain' as const, memo } : undefined, + } + } +} diff --git a/packages/chain-adapters/src/cosmossdk/mayachain/index.ts b/packages/chain-adapters/src/cosmossdk/mayachain/index.ts index 222f0c85f09..f51d4f64712 100644 --- a/packages/chain-adapters/src/cosmossdk/mayachain/index.ts +++ b/packages/chain-adapters/src/cosmossdk/mayachain/index.ts @@ -1 +1,2 @@ export * from './MayachainChainAdapter' +export * from './SecondClassMayachainAdapter' diff --git a/packages/chain-adapters/src/cosmossdk/thorchain/SecondClassThorchainAdapter.ts b/packages/chain-adapters/src/cosmossdk/thorchain/SecondClassThorchainAdapter.ts new file mode 100644 index 00000000000..c6921bd600b --- /dev/null +++ b/packages/chain-adapters/src/cosmossdk/thorchain/SecondClassThorchainAdapter.ts @@ -0,0 +1,664 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { + ASSET_REFERENCE, + fromChainId, + generateAssetIdFromCosmosSdkDenom, + thorchainAssetId, +} from '@shapeshiftoss/caip' +import type { HDWallet, ThorchainSignTx, ThorchainWallet } from '@shapeshiftoss/hdwallet-core' +import { supportsThorchain } from '@shapeshiftoss/hdwallet-core' +import type { Bip44Params, RootBip44Params } from '@shapeshiftoss/types' +import { KnownChainIds } from '@shapeshiftoss/types' +import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' +import { bech32 } from 'bech32' + +import type { ChainAdapter as IChainAdapter } from '../../api' +import { ChainAdapterError, ErrorHandler } from '../../error/ErrorHandler' +import type { + Account, + BroadcastTransactionInput, + BuildDepositTxInput, + BuildSendApiTxInput, + BuildSendTxInput, + FeeDataEstimate, + GetAddressInput, + GetBip44ParamsInput, + GetFeeDataInput, + SignAndBroadcastTransactionInput, + SignTxInput, + SubscribeError, + SubscribeTxsInput, + Transaction, + TxHistoryInput, + TxHistoryResponse, + ValidAddressResult, +} from '../../types' +import { ChainAdapterDisplayName, CONTRACT_INTERACTION, ValidAddressResultType } from '../../types' +import { toAddressNList, verifyLedgerAppOpen } from '../../utils' +import { bnOrZero } from '../../utils/bignumber' +import { assertAddressNotSanctioned } from '../../utils/validateAddress' +import type { + BuildTransactionInput, + CosmosSDKToken, + ThorchainMsgDeposit, + ThorchainMsgSend, + ThorSupportedCoin, +} from '../types' +import { ThorchainMessageType } from '../types' + +const NATIVE_FEE = '2000000' + +const calculateFee = (fee: string): string => { + const feeMinusAutomaticOutboundFee = bnOrZero(fee).minus(NATIVE_FEE) + return feeMinusAutomaticOutboundFee.gt(0) ? feeMinusAutomaticOutboundFee.toString() : '0' +} + +export type SecondClassThorchainAdapterArgs = { + nodeUrl: string + thorMidgardUrl: string + mayaMidgardUrl: string +} + +export class SecondClassThorchainAdapter implements IChainAdapter { + public static readonly rootBip44Params: RootBip44Params = { + purpose: 44, + coinType: Number(ASSET_REFERENCE.Thorchain), + accountNumber: 0, + } + + protected readonly nodeUrl: string + protected readonly thorMidgardUrl: string + protected readonly assetId: AssetId = thorchainAssetId + protected readonly chainId = KnownChainIds.ThorchainMainnet + protected readonly denom = 'rune' + + constructor(args: SecondClassThorchainAdapterArgs) { + this.nodeUrl = args.nodeUrl + this.thorMidgardUrl = args.thorMidgardUrl + } + + private assertSupportsChain(wallet: HDWallet): asserts wallet is ThorchainWallet { + if (!supportsThorchain(wallet)) { + throw new ChainAdapterError(`wallet does not support: ${this.getDisplayName()}`, { + translation: 'chainAdapters.errors.unsupportedChain', + options: { chain: this.getDisplayName() }, + }) + } + } + + getDisplayName() { + return ChainAdapterDisplayName.Thorchain + } + + getName() { + const enumIndex = Object.values(ChainAdapterDisplayName).indexOf( + ChainAdapterDisplayName.Thorchain, + ) + return Object.keys(ChainAdapterDisplayName)[enumIndex] + } + + getType(): KnownChainIds.ThorchainMainnet { + return KnownChainIds.ThorchainMainnet + } + + getChainId() { + return this.chainId + } + + getFeeAssetId(): AssetId { + return this.assetId + } + + getBip44Params({ accountNumber }: GetBip44ParamsInput): Bip44Params { + if (accountNumber < 0) throw new Error('accountNumber must be >= 0') + return { + ...SecondClassThorchainAdapter.rootBip44Params, + accountNumber, + isChange: false, + addressIndex: 0, + } + } + + async getAddress(input: GetAddressInput): Promise { + try { + const { wallet, accountNumber, pubKey, showOnDevice = false } = input + + if (pubKey) return pubKey + + if (!wallet) throw new Error('wallet is required') + this.assertSupportsChain(wallet) + await verifyLedgerAppOpen(this.chainId, wallet) + + const bip44Params = this.getBip44Params({ accountNumber }) + const address = await wallet.thorchainGetAddress({ + addressNList: toAddressNList(bip44Params), + showDisplay: showOnDevice, + }) + + if (!address) throw new Error('error getting address from wallet') + + return address + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getAddress', + }) + } + } + + // eslint-disable-next-line require-await + async validateAddress(address: string): Promise { + const THORCHAIN_PREFIX = 'thor' + + try { + const decoded = bech32.decode(address) + if (decoded.prefix !== THORCHAIN_PREFIX) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + + const wordsLength = decoded.words.length + if (wordsLength !== 32) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + return { valid: true, result: ValidAddressResultType.Valid } + } catch { + return { valid: false, result: ValidAddressResultType.Invalid } + } + } + + async getAccount(pubkey: string): Promise> { + try { + const [authRes, balanceRes] = await Promise.all([ + fetch(`${this.nodeUrl}/cosmos/auth/v1beta1/accounts/${pubkey}`), + fetch(`${this.nodeUrl}/cosmos/bank/v1beta1/balances/${pubkey}`), + ]) + + if (!authRes.ok) { + throw new Error(`Failed to fetch account: ${authRes.status} ${authRes.statusText}`) + } + if (!balanceRes.ok) { + throw new Error(`Failed to fetch balances: ${balanceRes.status} ${balanceRes.statusText}`) + } + + const authData = (await authRes.json()) as { + account: { account_number: string; sequence: string; pub_key?: { key: string } } + } + const balanceData = (await balanceRes.json()) as { + balances: { denom: string; amount: string }[] + } + + const assets = balanceData.balances.reduce((acc, b) => { + if (b.denom === this.denom) return acc + try { + acc.push({ + amount: b.amount, + assetId: generateAssetIdFromCosmosSdkDenom(b.denom), + }) + } catch {} + return acc + }, []) + + const nativeBalance = balanceData.balances.find(b => b.denom === this.denom)?.amount ?? '0' + + return { + balance: nativeBalance, + pubkey, + chainId: this.chainId, + assetId: this.assetId, + chain: this.getType(), + chainSpecific: { + accountNumber: authData.account.account_number, + sequence: authData.account.sequence, + assets, + delegations: [], + redelegations: [], + undelegations: [], + rewards: [], + }, + } as Account + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getAccount', + options: { pubkey }, + }) + } + } + + async broadcastTransaction({ + senderAddress, + receiverAddress, + hex, + }: BroadcastTransactionInput): Promise { + try { + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + const response = await fetch(`${this.nodeUrl}/cosmos/tx/v1beta1/txs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tx_bytes: hex, + mode: 'BROADCAST_MODE_SYNC', + }), + }) + + const data = (await response.json()) as { + tx_response?: { code: number; txhash: string; raw_log?: string } + } + + if (data.tx_response?.code !== 0) { + throw new Error(data.tx_response?.raw_log || 'Broadcast failed') + } + + return data.tx_response.txhash + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.broadcastTransaction', + }) + } + } + + async signTransaction(signTxInput: SignTxInput): Promise { + try { + const { txToSign, wallet } = signTxInput + + this.assertSupportsChain(wallet) + await verifyLedgerAppOpen(this.chainId, wallet) + + const signedTx = await wallet.thorchainSignTx(txToSign) + + if (!signedTx?.serialized) throw new Error('error signing tx') + + return signedTx.serialized + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.signTransaction', + }) + } + } + + async signAndBroadcastTransaction({ + senderAddress, + receiverAddress, + signTxInput, + }: SignAndBroadcastTransactionInput): Promise { + try { + const { wallet } = signTxInput + + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + this.assertSupportsChain(wallet) + + const hex = await this.signTransaction(signTxInput) + const txHash = await this.broadcastTransaction({ senderAddress, receiverAddress, hex }) + + return txHash + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.signAndBroadcastTransaction', + }) + } + } + + protected getAmount({ + account, + value, + fee, + sendMax, + }: { + account: Account + value: string + fee: string + sendMax?: boolean + }) { + if (!sendMax) return value + + const availableBalance = bnOrZero(account.balance).minus(fee) + + if (!availableBalance.isFinite() || availableBalance.lte(0)) { + throw new Error(`not enough balance to send: ${availableBalance.toString()}`) + } + + return availableBalance.toString() + } + + protected buildTransaction(input: BuildTransactionInput): { + txToSign: ThorchainSignTx + } { + const { account, accountNumber, msg, memo = '', chainSpecific } = input + const { gas, fee } = chainSpecific + + const bip44Params = this.getBip44Params({ accountNumber }) + + const unsignedTx = { + fee: { amount: [{ amount: bnOrZero(fee).toString(), denom: this.denom }], gas }, + msg: [msg], + signatures: [], + memo, + } + + const txToSign = { + addressNList: toAddressNList(bip44Params), + tx: unsignedTx, + chain_id: fromChainId(this.getType()).chainReference, + account_number: account.chainSpecific.accountNumber, + sequence: account.chainSpecific.sequence, + } as unknown as ThorchainSignTx + + return { txToSign } + } + + async buildSendApiTransaction( + input: BuildSendApiTxInput, + ): Promise<{ txToSign: ThorchainSignTx }> { + try { + const { sendMax, to, value, from, chainSpecific } = input + const { coin = 'THOR.RUNE', fee } = chainSpecific + + if (coin !== 'THOR.RUNE' && coin !== 'THOR.TCY' && coin !== 'THOR.RUJI') + throw new Error('unsupported coin type') + + if (!fee) throw new Error('fee is required') + + const account = await this.getAccount(from) + const amount = coin === 'THOR.RUNE' ? this.getAmount({ account, value, fee, sendMax }) : value + + const denom = (() => { + if (coin === 'THOR.TCY') return 'tcy' + if (coin === 'THOR.RUJI') return 'x/ruji' + return this.denom + })() + + const msg: ThorchainMsgSend = { + type: ThorchainMessageType.MsgSend, + value: { + amount: [{ amount, denom }], + from_address: from, + to_address: to, + }, + } + + const tx = Object.assign(input, { + account, + msg, + chainSpecific: { ...input.chainSpecific, fee: calculateFee(fee) }, + }) + + return this.buildTransaction(tx) + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.buildTransaction', + }) + } + } + + async buildSendTransaction( + input: BuildSendTxInput, + ): Promise<{ txToSign: ThorchainSignTx }> { + try { + const { accountNumber, wallet } = input + + const from = await this.getAddress({ accountNumber, wallet }) + const tx = await this.buildSendApiTransaction({ ...input, from }) + + return tx + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.buildTransaction', + }) + } + } + + async buildDepositTransaction( + input: BuildDepositTxInput, + ): Promise<{ txToSign: ThorchainSignTx }> { + try { + const { from, value, memo, chainSpecific } = input + const { fee, coin = 'THOR.RUNE' } = chainSpecific + + if (!['THOR.TCY', 'THOR.RUJI', 'THOR.RUNE'].includes(coin)) + throw new Error('unsupported coin type') + + if (!fee) throw new Error('fee is required') + + const account = await this.getAccount(from) + + const msg: ThorchainMsgDeposit = { + type: ThorchainMessageType.MsgDeposit, + value: { + coins: [{ asset: coin as ThorSupportedCoin, amount: bnOrZero(value).toString() }], + memo, + signer: from, + }, + } + + const tx = Object.assign(input, { + account, + msg, + chainSpecific: { ...input.chainSpecific, fee: calculateFee(fee) }, + }) + + return this.buildTransaction(tx) + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.buildTransaction', + }) + } + } + + // eslint-disable-next-line require-await + async getFeeData( + _: Partial>, + ): Promise> { + return { + fast: { txFee: NATIVE_FEE, chainSpecific: { gasLimit: '500000000' } }, + average: { txFee: NATIVE_FEE, chainSpecific: { gasLimit: '500000000' } }, + slow: { txFee: NATIVE_FEE, chainSpecific: { gasLimit: '500000000' } }, + } + } + + getTxHistory(_input: TxHistoryInput): Promise { + return Promise.resolve({ + cursor: '', + pubkey: _input.pubkey, + transactions: [], + txIds: [], + }) + } + + subscribeTxs( + _input: SubscribeTxsInput, + _onMessage: (msg: Transaction) => void, + _onError: (err: SubscribeError) => void, + ): Promise { + return Promise.resolve() + } + + unsubscribeTxs(_input?: SubscribeTxsInput): void { + return + } + + closeTxs(): void { + return + } + + async parseTx(txHash: unknown, pubkey: string): Promise { + const hash = txHash as string + + const getAssetIdFromDenom = (denom: string): AssetId => { + const normalizedDenom = denom.toLowerCase().replace('thor.', '') + if (normalizedDenom === this.denom) return this.assetId + try { + return generateAssetIdFromCosmosSdkDenom(normalizedDenom) + } catch { + return this.assetId + } + } + + const response = await fetch(`${this.nodeUrl}/cosmos/tx/v1beta1/txs/${hash}`) + + if (response.ok) { + const data = (await response.json()) as { + tx_response: { + txhash: string + height: string + code: number + timestamp: string + tx: { + body: { + messages: { + '@type': string + from_address?: string + to_address?: string + amount?: { denom: string; amount: string }[] + coins?: { asset: string; amount: string }[] + memo?: string + signer?: string + }[] + memo?: string + } + } + } + } + + const txResponse = data.tx_response + const messages = txResponse.tx.body.messages + const status = txResponse.code === 0 ? TxStatus.Confirmed : TxStatus.Failed + const blockHeight = parseInt(txResponse.height, 10) + const blockTime = Math.floor(new Date(txResponse.timestamp).getTime() / 1000) + + const transfers: Transaction['transfers'] = [] + + for (const msg of messages) { + if (msg['@type'] === '/types.MsgSend' && msg.from_address && msg.to_address && msg.amount) { + for (const coin of msg.amount) { + const isSend = msg.from_address.toLowerCase() === pubkey.toLowerCase() + const isReceive = msg.to_address.toLowerCase() === pubkey.toLowerCase() + + if (isSend || isReceive) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.denom), + from: [msg.from_address], + to: [msg.to_address], + type: isSend ? TransferType.Send : TransferType.Receive, + value: coin.amount, + }) + } + } + } else if (msg['@type'] === '/types.MsgDeposit' && msg.signer && msg.coins) { + for (const coin of msg.coins) { + const isSigner = msg.signer.toLowerCase() === pubkey.toLowerCase() + if (isSigner) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.asset), + from: [msg.signer], + to: [msg.signer], + type: TransferType.Send, + value: coin.amount, + }) + } + } + } + } + + return { + txid: txResponse.txhash, + blockHash: '', + blockHeight, + blockTime, + confirmations: 1, + status, + chainId: this.chainId, + pubkey, + transfers, + data: txResponse.tx.body.memo + ? { parser: 'thorchain' as const, memo: txResponse.tx.body.memo } + : undefined, + } + } + + // Cosmos endpoint doesn't work for some txs (e.g. TCY), fall back to midgard + const midgardResponse = await fetch(`${this.thorMidgardUrl}/actions?txid=${hash}`) + + if (!midgardResponse.ok) { + throw new Error( + `Failed to fetch transaction: ${midgardResponse.status} ${midgardResponse.statusText}`, + ) + } + + const midgardData = (await midgardResponse.json()) as { + actions: { + date: string + height: string + status: string + type: string + in: { address: string; coins: { amount: string; asset: string }[]; txID: string }[] + out: { address: string; coins: { amount: string; asset: string }[]; txID: string }[] + metadata?: { send?: { memo?: string }; swap?: { memo?: string } } + }[] + } + + if (!midgardData.actions?.length) { + throw new Error(`Transaction not found: ${hash}`) + } + + const action = midgardData.actions[0] + const status = action.status === 'success' ? TxStatus.Confirmed : TxStatus.Failed + const blockHeight = parseInt(action.height, 10) + const blockTime = Math.floor(parseInt(action.date, 10) / 1_000_000_000) + + const transfers: Transaction['transfers'] = [] + + for (const input of action.in) { + for (const coin of input.coins) { + const isSend = input.address.toLowerCase() === pubkey.toLowerCase() + if (isSend) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.asset), + from: [input.address], + to: [action.out[0]?.address ?? input.address], + type: TransferType.Send, + value: coin.amount, + }) + } + } + } + + for (const output of action.out) { + for (const coin of output.coins) { + const isReceive = output.address.toLowerCase() === pubkey.toLowerCase() + const alreadyAdded = transfers.some( + t => t.type === TransferType.Send && t.to.includes(output.address), + ) + if (isReceive && !alreadyAdded) { + transfers.push({ + assetId: getAssetIdFromDenom(coin.asset), + from: [action.in[0]?.address ?? output.address], + to: [output.address], + type: TransferType.Receive, + value: coin.amount, + }) + } + } + } + + const memo = action.metadata?.send?.memo ?? action.metadata?.swap?.memo + + return { + txid: hash, + blockHash: '', + blockHeight, + blockTime, + confirmations: 1, + status, + chainId: this.chainId, + pubkey, + transfers, + data: memo ? { parser: 'thorchain' as const, memo } : undefined, + } + } +} diff --git a/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts b/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts index 11b547958f3..427c67ab84e 100644 --- a/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts +++ b/packages/chain-adapters/src/cosmossdk/thorchain/ThorchainChainAdapter.ts @@ -1,5 +1,9 @@ import type { AssetId } from '@shapeshiftoss/caip' -import { ASSET_REFERENCE, thorchainAssetId } from '@shapeshiftoss/caip' +import { + ASSET_REFERENCE, + generateAssetIdFromCosmosSdkDenom, + thorchainAssetId, +} from '@shapeshiftoss/caip' import type { HDWallet, ThorchainSignTx, ThorchainWallet } from '@shapeshiftoss/hdwallet-core' import { supportsThorchain } from '@shapeshiftoss/hdwallet-core' import type { RootBip44Params } from '@shapeshiftoss/types' @@ -10,6 +14,8 @@ import PQueue from 'p-queue' import { ChainAdapterError, ErrorHandler } from '../../error/ErrorHandler' import type { + Account, + BroadcastTransactionInput, BuildDepositTxInput, BuildSendApiTxInput, BuildSendTxInput, @@ -50,6 +56,7 @@ export interface ChainAdapterArgs extends BaseChainAdapterArgs { @@ -60,6 +67,7 @@ export class ChainAdapter extends CosmosSdkBaseAdapter> { + try { + const [authRes, balanceRes] = await Promise.all([ + fetch(`${this.nodeUrl}/cosmos/auth/v1beta1/accounts/${pubkey}`), + fetch(`${this.nodeUrl}/cosmos/bank/v1beta1/balances/${pubkey}`), + ]) + + if (!authRes.ok) { + throw new Error(`Failed to fetch account: ${authRes.status} ${authRes.statusText}`) + } + if (!balanceRes.ok) { + throw new Error(`Failed to fetch balances: ${balanceRes.status} ${balanceRes.statusText}`) + } + + const authData = (await authRes.json()) as { + account: { account_number: string; sequence: string; pub_key?: { key: string } } + } + const balanceData = (await balanceRes.json()) as { + balances: { denom: string; amount: string }[] + } + + const assets = balanceData.balances.reduce<{ amount: string; assetId: AssetId }[]>( + (acc, b) => { + if (b.denom === this.denom) return acc + try { + acc.push({ + amount: b.amount, + assetId: generateAssetIdFromCosmosSdkDenom(b.denom), + }) + } catch {} + return acc + }, + [], + ) + + const nativeBalance = balanceData.balances.find(b => b.denom === this.denom)?.amount ?? '0' + + return { + balance: nativeBalance, + pubkey, + chainId: this.chainId, + assetId: this.assetId, + chain: this.getType(), + chainSpecific: { + accountNumber: authData.account.account_number, + sequence: authData.account.sequence, + assets, + delegations: [], + redelegations: [], + undelegations: [], + rewards: [], + }, + } as Account + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.getAccount', + options: { pubkey }, + }) + } + } + + async broadcastTransaction({ + senderAddress, + receiverAddress, + hex, + }: BroadcastTransactionInput): Promise { + try { + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + const response = await fetch(`${this.nodeUrl}/cosmos/tx/v1beta1/txs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tx_bytes: hex, + mode: 'BROADCAST_MODE_SYNC', + }), + }) + + const data = (await response.json()) as { + tx_response?: { code: number; txhash: string; raw_log?: string } + } + + if (data.tx_response?.code !== 0) { + throw new Error(data.tx_response?.raw_log || 'Broadcast failed') + } + + return data.tx_response.txhash + } catch (err) { + return ErrorHandler(err, { + translation: 'chainAdapters.errors.broadcastTransaction', + }) + } + } } diff --git a/packages/chain-adapters/src/cosmossdk/thorchain/index.ts b/packages/chain-adapters/src/cosmossdk/thorchain/index.ts index 6c16c2c70ce..05a9883f32e 100644 --- a/packages/chain-adapters/src/cosmossdk/thorchain/index.ts +++ b/packages/chain-adapters/src/cosmossdk/thorchain/index.ts @@ -1 +1,2 @@ export * from './ThorchainChainAdapter' +export * from './SecondClassThorchainAdapter' diff --git a/packages/swapper/src/swappers/MayachainSwapper/endpoints.ts b/packages/swapper/src/swappers/MayachainSwapper/endpoints.ts index 96408f8653c..070d4590903 100644 --- a/packages/swapper/src/swappers/MayachainSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/MayachainSwapper/endpoints.ts @@ -90,8 +90,7 @@ export const mayachainApi: SwapperApi = { const { config } = input const nodeUrl = `${config.VITE_MAYACHAIN_NODE_URL}/mayachain` - const apiUrl = `${config.VITE_UNCHAINED_MAYACHAIN_HTTP_URL}/api/v1` - return checkTradeStatus({ ...input, nodeUrl, apiUrl, nativeChain: 'MAYA' }) + return checkTradeStatus({ ...input, nodeUrl, nativeChain: 'MAYA' }) }, } diff --git a/packages/swapper/src/swappers/ThorchainSwapper/endpoints.ts b/packages/swapper/src/swappers/ThorchainSwapper/endpoints.ts index ad20a1b0d4f..d3f2026512a 100644 --- a/packages/swapper/src/swappers/ThorchainSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/ThorchainSwapper/endpoints.ts @@ -102,8 +102,7 @@ export const thorchainApi: SwapperApi = { const { config } = input const nodeUrl = `${config.VITE_THORCHAIN_NODE_URL}/thorchain` - const apiUrl = `${config.VITE_UNCHAINED_THORCHAIN_HTTP_URL}/api/v1` - return checkTradeStatus({ ...input, nodeUrl, apiUrl, nativeChain: 'THOR' }) + return checkTradeStatus({ ...input, nodeUrl, nativeChain: 'THOR' }) }, } diff --git a/packages/swapper/src/thorchain-utils/checkTradeStatus.ts b/packages/swapper/src/thorchain-utils/checkTradeStatus.ts index 27258f801f9..d88941f5667 100644 --- a/packages/swapper/src/thorchain-utils/checkTradeStatus.ts +++ b/packages/swapper/src/thorchain-utils/checkTradeStatus.ts @@ -1,5 +1,4 @@ import { mayachainChainId, thorchainChainId } from '@shapeshiftoss/caip' -import type { cosmossdk } from '@shapeshiftoss/unchained-client' import { TxStatus } from '@shapeshiftoss/unchained-client' import axios from 'axios' @@ -11,7 +10,6 @@ import type { ThornodeStatusResponse, ThornodeTxResponse } from './types' type CheckTradeStatusInputExtended = CheckTradeStatusInput & { nodeUrl: string - apiUrl: string nativeChain: 'THOR' | 'MAYA' } @@ -22,7 +20,6 @@ export const checkTradeStatus = async ({ fetchIsSmartContractAddressQuery, assertGetEvmChainAdapter, nodeUrl, - apiUrl, nativeChain, }: CheckTradeStatusInputExtended): Promise => { try { @@ -48,14 +45,21 @@ export const checkTradeStatus = async ({ (chainId === thorchainChainId && nativeChain === 'THOR') || (chainId === mayachainChainId && nativeChain === 'MAYA') ) { - const { data: tx } = await axios.get(`${apiUrl}/tx/${txHash}`) - - if (tx.events['0']?.error) { - return { - status: TxStatus.Failed, - buyTxHash: undefined, - message: tx.events[0].error.message, + try { + const cosmosNodeUrl = nodeUrl.replace('/thorchain', '').replace('/mayachain', '') + const { data: txResponse } = await axios.get<{ + tx_response?: { code: number; raw_log?: string } + }>(`${cosmosNodeUrl}/cosmos/tx/v1beta1/txs/${txHash}`) + + if (txResponse.tx_response && txResponse.tx_response.code !== 0) { + return { + status: TxStatus.Failed, + buyTxHash: undefined, + message: txResponse.tx_response.raw_log, + } } + } catch { + // Cosmos endpoint may 404 for some txs (e.g. MsgDeposit), continue with thorchain status check } } diff --git a/src/constants/chains.ts b/src/constants/chains.ts index f85da5128a6..af70c1e08df 100644 --- a/src/constants/chains.ts +++ b/src/constants/chains.ts @@ -11,6 +11,8 @@ export const SECOND_CLASS_CHAINS: readonly KnownChainIds[] = [ KnownChainIds.MonadMainnet, KnownChainIds.HyperEvmMainnet, KnownChainIds.PlasmaMainnet, + KnownChainIds.ThorchainMainnet, + KnownChainIds.MayachainMainnet, ] // returns known ChainIds as an array, excluding the ones that are currently flagged off diff --git a/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx b/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx index a69ca814192..a61d84c7e59 100644 --- a/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx +++ b/src/hooks/useActionCenterSubscribers/useSendActionSubscriber.tsx @@ -12,9 +12,11 @@ import { getConfig } from '@/config' import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { getHyperEvmTransactionStatus } from '@/lib/utils/hyperevm' +import { getMayachainTransactionStatus } from '@/lib/utils/mayachain' import { getMonadTransactionStatus } from '@/lib/utils/monad' import { getPlasmaTransactionStatus } from '@/lib/utils/plasma' import { getSuiTransactionStatus } from '@/lib/utils/sui' +import { getThorchainSendTransactionStatus } from '@/lib/utils/thorchain' import { getTronTransactionStatus } from '@/lib/utils/tron' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { selectPendingWalletSendActions } from '@/state/slices/actionSlice/selectors' @@ -154,6 +156,18 @@ export const useSendActionSubscriber = () => { hyperEvmTxStatus === TxStatus.Confirmed || hyperEvmTxStatus === TxStatus.Failed break } + case KnownChainIds.ThorchainMainnet: { + const thorTxStatus = await getThorchainSendTransactionStatus(txHash) + isConfirmed = + thorTxStatus === TxStatus.Confirmed || thorTxStatus === TxStatus.Failed + break + } + case KnownChainIds.MayachainMainnet: { + const mayaTxStatus = await getMayachainTransactionStatus(txHash) + isConfirmed = + mayaTxStatus === TxStatus.Confirmed || mayaTxStatus === TxStatus.Failed + break + } default: console.error(`Unsupported second-class chain: ${chainId}`) return diff --git a/src/lib/utils/mayachain.ts b/src/lib/utils/mayachain.ts new file mode 100644 index 00000000000..85d81d8684f --- /dev/null +++ b/src/lib/utils/mayachain.ts @@ -0,0 +1,54 @@ +import type { ChainId } from '@shapeshiftoss/caip' +import { mayachainChainId } from '@shapeshiftoss/caip' +import type { mayachain } from '@shapeshiftoss/chain-adapters' +import type { KnownChainIds } from '@shapeshiftoss/types' +import { TxStatus } from '@shapeshiftoss/unchained-client' + +import { getConfig } from '@/config' +import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' + +export const isMayachainChainAdapter = ( + chainAdapter: unknown, +): chainAdapter is mayachain.SecondClassMayachainAdapter => { + if (!chainAdapter || typeof chainAdapter !== 'object') return false + const adapter = chainAdapter as mayachain.SecondClassMayachainAdapter + if (typeof adapter.getChainId !== 'function') return false + return adapter.getChainId() === mayachainChainId +} + +export const assertGetMayachainChainAdapter = ( + chainId: ChainId | KnownChainIds, +): mayachain.SecondClassMayachainAdapter => { + const chainAdapterManager = getChainAdapterManager() + const adapter = chainAdapterManager.get(chainId) + + if (!isMayachainChainAdapter(adapter)) { + throw Error('invalid chain adapter') + } + + return adapter +} + +export const getMayachainTransactionStatus = async (txHash: string): Promise => { + try { + const nodeUrl = getConfig().VITE_MAYACHAIN_NODE_URL + const response = await fetch(`${nodeUrl}/cosmos/tx/v1beta1/txs/${txHash}`) + + if (!response.ok) { + if (response.status === 404) return TxStatus.Pending + return TxStatus.Unknown + } + + const data = (await response.json()) as { + tx_response?: { code: number; txhash: string } + } + + if (!data.tx_response) return TxStatus.Unknown + + if (data.tx_response.code === 0) return TxStatus.Confirmed + return TxStatus.Failed + } catch (error) { + console.error('Error getting MAYAChain transaction status:', error) + return TxStatus.Unknown + } +} diff --git a/src/lib/utils/thorchain/index.ts b/src/lib/utils/thorchain/index.ts index 4becff58d45..c54ca4ce1fa 100644 --- a/src/lib/utils/thorchain/index.ts +++ b/src/lib/utils/thorchain/index.ts @@ -38,6 +38,40 @@ import { poll } from '@/lib/poll/poll' import type { getThorchainLpPosition } from '@/pages/ThorChainLP/queries/queries' import { getThorchainSaversPosition } from '@/state/slices/opportunitiesSlice/resolvers/thorchainsavers/utils' +export const getThorchainSendTransactionStatus = async (txHash: string): Promise => { + try { + const nodeUrl = getConfig().VITE_THORCHAIN_NODE_URL + const response = await axios.get(`${nodeUrl}/cosmos/tx/v1beta1/txs/${txHash}`, { + validateStatus: () => true, + }) + + if (response.data?.tx_response) { + if (response.data.tx_response.code === 0) return TxStatus.Confirmed + return TxStatus.Failed + } + + // Cosmos endpoint doesn't work for MsgDeposit txs (RUNE/TCY/RUJI internal sends) + // Fall back to midgard + const midgardUrl = getConfig().VITE_THORCHAIN_MIDGARD_URL + const midgardResponse = await axios.get( + `${midgardUrl}/actions?txid=${txHash}`, + { validateStatus: () => true }, + ) + + if (midgardResponse.data?.actions?.length) { + const action = midgardResponse.data.actions[0] + if (action.status === 'success') return TxStatus.Confirmed + if (action.status === 'pending') return TxStatus.Pending + return TxStatus.Failed + } + + return TxStatus.Pending + } catch (error) { + console.error('Error getting THORChain send transaction status:', error) + return TxStatus.Unknown + } +} + export const getThorchainTransactionStatus = async ({ txHash, skipOutbound, diff --git a/src/plugins/mayachain/index.tsx b/src/plugins/mayachain/index.tsx index d8c22ca91b9..265b05e0c1f 100644 --- a/src/plugins/mayachain/index.tsx +++ b/src/plugins/mayachain/index.tsx @@ -1,6 +1,5 @@ import { mayachain } from '@shapeshiftoss/chain-adapters' import { KnownChainIds } from '@shapeshiftoss/types' -import * as unchained from '@shapeshiftoss/unchained-client' import { getConfig } from '@/config' import type { Plugins } from '@/plugins/types' @@ -17,19 +16,8 @@ export default function register(): Plugins { [ KnownChainIds.MayachainMainnet, () => { - const http = new unchained.mayachain.V1Api( - new unchained.mayachain.Configuration({ - basePath: getConfig().VITE_UNCHAINED_MAYACHAIN_HTTP_URL, - }), - ) - - const ws = new unchained.ws.Client( - getConfig().VITE_UNCHAINED_MAYACHAIN_WS_URL, - ) - - return new mayachain.ChainAdapter({ - providers: { http, ws }, - coinName: 'Mayachain', + return new mayachain.SecondClassMayachainAdapter({ + nodeUrl: getConfig().VITE_MAYACHAIN_NODE_URL, midgardUrl: getConfig().VITE_MAYACHAIN_MIDGARD_URL, }) }, diff --git a/src/plugins/thorchain/index.tsx b/src/plugins/thorchain/index.tsx index 43d233b59cc..9a5285ea9c7 100644 --- a/src/plugins/thorchain/index.tsx +++ b/src/plugins/thorchain/index.tsx @@ -1,6 +1,5 @@ import { thorchain } from '@shapeshiftoss/chain-adapters' import { KnownChainIds } from '@shapeshiftoss/types' -import * as unchained from '@shapeshiftoss/unchained-client' import { getConfig } from '@/config' import type { Plugins } from '@/plugins/types' @@ -17,28 +16,10 @@ export default function register(): Plugins { [ KnownChainIds.ThorchainMainnet, () => { - const http = new unchained.thorchain.V1Api( - new unchained.thorchain.Configuration({ - basePath: getConfig().VITE_UNCHAINED_THORCHAIN_HTTP_URL, - }), - ) - - const httpV1 = new unchained.thorchainV1.V1Api( - new unchained.thorchainV1.Configuration({ - basePath: getConfig().VITE_UNCHAINED_THORCHAIN_V1_HTTP_URL, - }), - ) - - const ws = new unchained.ws.Client( - getConfig().VITE_UNCHAINED_THORCHAIN_WS_URL, - ) - - return new thorchain.ChainAdapter({ - providers: { http, ws }, - coinName: 'Thorchain', + return new thorchain.SecondClassThorchainAdapter({ + nodeUrl: getConfig().VITE_THORCHAIN_NODE_URL, thorMidgardUrl: getConfig().VITE_THORCHAIN_MIDGARD_URL, mayaMidgardUrl: getConfig().VITE_MAYACHAIN_MIDGARD_URL, - httpV1, }) }, ],