From ed1a2012737184bbd6f57fe6ca51a57b2a49fc34 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 1 Oct 2026 21:18:15 -0700 Subject: [PATCH 1/2] Accept THORChain tokens from the info server THORChain had no synchronous createTokenId, so infoServerTokens entries were dropped. Share the Cosmos denom validation between the tools and the outer plugin, anchor it to the full cosmos-sdk denom charset, and reject remote entries that reuse the native denom or a known currency code. --- src/common/tokenHelpers.ts | 26 ++ src/cosmos/CosmosTools.ts | 26 +- src/cosmos/info/thorchainCommonInfo.ts | 54 ++++ src/cosmos/info/thorchainruneInfo.ts | 6 + src/cosmos/info/thorchainruneStagenetInfo.ts | 6 + test/cosmos/nativeDenomTokenId.test.ts | 72 ++++++ test/cosmos/thorchainInfoServerTokens.test.ts | 234 ++++++++++++++++++ 7 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 src/cosmos/info/thorchainCommonInfo.ts create mode 100644 test/cosmos/nativeDenomTokenId.test.ts create mode 100644 test/cosmos/thorchainInfoServerTokens.test.ts diff --git a/src/common/tokenHelpers.ts b/src/common/tokenHelpers.ts index 56ec0ae09..23b5ad04f 100644 --- a/src/common/tokenHelpers.ts +++ b/src/common/tokenHelpers.ts @@ -83,3 +83,29 @@ export const createTokenIdFromContractAddress = (token: EdgeToken): string => { export const createEvmTokenId = (token: EdgeToken): string => { return normalizeAddress(createTokenIdFromContractAddress(token)) } + +/** + * Derives a token ID from a Cosmos bank denom. + * The raw denom stays in `networkLocation.contractAddress`, + * since that is what the chain uses for balances and sends. + */ +export const createCosmosTokenId = (token: EdgeToken): string => { + validateToken(token) + const { contractAddress } = + asMaybeContractLocation(token.networkLocation) ?? {} + + // Denom rules from the cosmos-sdk (types/coin.go), + // split so an IBC denom must carry its full hash: + const ibcDenomRegex = /^ibc\/[0-9A-F]{64}$/ + const nativeDenomRegex = /^(?!ibc)[a-zA-Z][a-zA-Z0-9/:._-]{2,127}$/ + + if ( + contractAddress == null || + (!ibcDenomRegex.test(contractAddress) && + !nativeDenomRegex.test(contractAddress)) + ) { + throw new Error('ErrorInvalidContractAddress') + } + + return contractAddress.toLowerCase().replace(/\//g, '') +} diff --git a/src/cosmos/CosmosTools.ts b/src/cosmos/CosmosTools.ts index a0a0ec3d0..8a35a48bc 100644 --- a/src/cosmos/CosmosTools.ts +++ b/src/cosmos/CosmosTools.ts @@ -21,7 +21,10 @@ import { import { base16 } from 'rfc4648' import { PluginEnvironment } from '../common/innerPlugin' -import { asMaybeContractLocation, validateToken } from '../common/tokenHelpers' +import { + asMaybeContractLocation, + createCosmosTokenId +} from '../common/tokenHelpers' import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' import { getLegacyDenomination, mergeDeeply } from '../common/utils' import { upgradeRegistryAndCreateMethods } from './cosmosRegistry' @@ -239,24 +242,19 @@ export class CosmosTools implements EdgeCurrencyTools { } async getTokenId(token: EdgeToken): Promise { - validateToken(token) - const { contractAddress } = - asMaybeContractLocation(token.networkLocation) ?? {} - - // Regexes inspired by a general regex in https://github.com/cosmos/cosmos-sdk - // Broken up to more tightly enforce the rules for each type of asset so the entered value matches what a node would expect - const ibcDenomRegex = /^ibc\/[0-9A-F]{64}$/ - const nativeDenomRegex = /^(?!ibc)[a-z][a-z0-9/]{2,127}/ + const tokenId = createCosmosTokenId(token) + // The native denom is the native currency, not a token. + // Adding it as one would show the same balance twice: + const { contractAddress = '' } = + asMaybeContractLocation(token.networkLocation) ?? {} if ( - contractAddress == null || - (!ibcDenomRegex.test(contractAddress) && - !nativeDenomRegex.test(contractAddress)) + contractAddress.toLowerCase() === + this.networkInfo.nativeDenom.toLowerCase() ) { throw new Error('ErrorInvalidContractAddress') } - - return contractAddress.toLowerCase().replace(/\//g, '') + return tokenId } } diff --git a/src/cosmos/info/thorchainCommonInfo.ts b/src/cosmos/info/thorchainCommonInfo.ts new file mode 100644 index 000000000..d95f81e7c --- /dev/null +++ b/src/cosmos/info/thorchainCommonInfo.ts @@ -0,0 +1,54 @@ +import type { EdgeToken, EdgeTokenMap } from 'edge-core-js/types' + +import { + asMaybeContractLocation, + createCosmosTokenId +} from '../../common/tokenHelpers' + +interface ThorchainTokenIdOpts { + /** The plugin's token map, which grows as remote tokens are accepted. */ + builtinTokens: EdgeTokenMap + currencyCode: string + nativeDenom: string +} + +/** + * Makes the `createTokenId` method used to accept remote token definitions. + * + * THORChain tokens are plain bank denoms, which are always lowercase. + * A definition is rejected when its denom is malformed or is the native + * denom, or when its currency code is already taken by the native asset or + * by another token, since the engine looks tokens up by both. + */ +export const makeThorchainCreateTokenId = ( + opts: ThorchainTokenIdOpts +): ((token: EdgeToken) => string) => { + const { builtinTokens, currencyCode, nativeDenom } = opts + + return token => { + const tokenId = createCosmosTokenId(token) + const { contractAddress = '' } = + asMaybeContractLocation(token.networkLocation) ?? {} + + if ( + contractAddress !== contractAddress.toLowerCase() || + contractAddress === nativeDenom + ) { + throw new Error('ErrorInvalidContractAddress') + } + + const tokenCode = token.currencyCode.toUpperCase() + const isCodeTaken = + tokenCode === currencyCode.toUpperCase() || + Object.keys(builtinTokens).some( + knownId => + knownId !== tokenId && + builtinTokens[knownId].currencyCode.toUpperCase() === tokenCode + ) + if (isCodeTaken) { + throw new Error(`Duplicate currency code "${token.currencyCode}"`) + } + + return tokenId + } +} diff --git a/src/cosmos/info/thorchainruneInfo.ts b/src/cosmos/info/thorchainruneInfo.ts index 2c6a220ba..8350e4021 100644 --- a/src/cosmos/info/thorchainruneInfo.ts +++ b/src/cosmos/info/thorchainruneInfo.ts @@ -5,6 +5,7 @@ import type { CosmosTools } from '../CosmosTools' import { asCosmosInfoPayload, CosmosInfoPayload } from '../cosmosTypes' import { MidgardNetworkInfo } from '../midgardTypes' import { makeCosmosDefaultSettings } from './cosmosCommonInfo' +import { makeThorchainCreateTokenId } from './thorchainCommonInfo' // import { cosmosCustomTokenTemplate } from './cosmosCommonInfo' const builtinTokens: EdgeTokenMap = { @@ -100,6 +101,11 @@ export const thorchainrune = makeOuterPlugin< asInfoPayload: asCosmosInfoPayload, networkInfo, builtinTokens, + createTokenId: makeThorchainCreateTokenId({ + builtinTokens, + currencyCode: currencyInfo.currencyCode, + nativeDenom: networkInfo.nativeDenom + }), checkEnvironment() { if (global.BigInt == null) { diff --git a/src/cosmos/info/thorchainruneStagenetInfo.ts b/src/cosmos/info/thorchainruneStagenetInfo.ts index 525813398..3d76f5a41 100644 --- a/src/cosmos/info/thorchainruneStagenetInfo.ts +++ b/src/cosmos/info/thorchainruneStagenetInfo.ts @@ -5,6 +5,7 @@ import type { CosmosTools } from '../CosmosTools' import { asCosmosInfoPayload, CosmosInfoPayload } from '../cosmosTypes' import { MidgardNetworkInfo } from '../midgardTypes' import { makeCosmosDefaultSettings } from './cosmosCommonInfo' +import { makeThorchainCreateTokenId } from './thorchainCommonInfo' // import { cosmosCustomTokenTemplate } from './cosmosCommonInfo' const builtinTokens: EdgeTokenMap = { @@ -93,6 +94,11 @@ export const thorchainrunestagenet = makeOuterPlugin< asInfoPayload: asCosmosInfoPayload, networkInfo, builtinTokens, + createTokenId: makeThorchainCreateTokenId({ + builtinTokens, + currencyCode: currencyInfo.currencyCode, + nativeDenom: networkInfo.nativeDenom + }), checkEnvironment() { if (global.BigInt == null) { diff --git a/test/cosmos/nativeDenomTokenId.test.ts b/test/cosmos/nativeDenomTokenId.test.ts new file mode 100644 index 000000000..9da0bb179 --- /dev/null +++ b/test/cosmos/nativeDenomTokenId.test.ts @@ -0,0 +1,72 @@ +import { assert } from 'chai' +import { EdgeCorePluginOptions, EdgeToken, makeFakeIo } from 'edge-core-js' +import { describe, it } from 'mocha' + +import plugins from '../../src/index' +import { expectRejection } from '../expectRejection' +import { fakeLog } from '../fake/fakeLog' + +const fakeIo = makeFakeIo() +const opts: EdgeCorePluginOptions = { + infoPayload: {}, + initOptions: {}, + io: fakeIo, + log: fakeLog, + nativeIo: {}, + pluginDisklet: fakeIo.disklet +} + +const makeToken = (contractAddress: string): EdgeToken => ({ + currencyCode: 'TEST', + denominations: [{ name: 'TEST', multiplier: '100000000' }], + displayName: 'Test Token', + networkLocation: { contractAddress } +}) + +// Each Cosmos plugin with its native denom: +const nativeDenoms: Array<[keyof typeof plugins, string]> = [ + ['axelar', 'uaxl'], + ['coreum', 'ucore'], + ['cosmoshub', 'uatom'], + ['mayachain', 'cacao'], + ['nym', 'unym'], + ['osmosis', 'uosmo'], + ['thorchainrune', 'rune'], + ['thorchainrunestagenet', 'rune'] +] + +describe('CosmosTools.getTokenId with the native denom', function () { + this.timeout(30000) + + for (const [pluginId, nativeDenom] of nativeDenoms) { + it(`rejects ${nativeDenom} on ${pluginId}`, async function () { + const tools = await plugins[pluginId](opts).makeCurrencyTools() + if (tools.getTokenId == null) throw new Error('Missing getTokenId') + + await expectRejection( + tools.getTokenId(makeToken(nativeDenom)), + 'Error: ErrorInvalidContractAddress' + ) + // The engine matches balances to denoms without regard to case: + await expectRejection( + tools.getTokenId(makeToken(nativeDenom.toUpperCase())), + 'Error: ErrorInvalidContractAddress' + ) + }) + } + + it('still accepts other denoms', async function () { + const tools = await plugins.thorchainrune(opts).makeCurrencyTools() + if (tools.getTokenId == null) throw new Error('Missing getTokenId') + + assert.equal(await tools.getTokenId(makeToken('x/ruji')), 'xruji') + assert.equal(await tools.getTokenId(makeToken('runes')), 'runes') + }) + + it("accepts another chain's native denom", async function () { + const tools = await plugins.osmosis(opts).makeCurrencyTools() + if (tools.getTokenId == null) throw new Error('Missing getTokenId') + + assert.equal(await tools.getTokenId(makeToken('rune')), 'rune') + }) +}) diff --git a/test/cosmos/thorchainInfoServerTokens.test.ts b/test/cosmos/thorchainInfoServerTokens.test.ts new file mode 100644 index 000000000..adf7fea79 --- /dev/null +++ b/test/cosmos/thorchainInfoServerTokens.test.ts @@ -0,0 +1,234 @@ +import { assert } from 'chai' +import { makeFakeIo } from 'edge-core-js' +import { + EdgeCurrencyPlugin, + EdgeToken, + EdgeTokenMap, + JsonObject +} from 'edge-core-js/types' +import { afterEach, describe, it } from 'mocha' + +import { createCosmosTokenId } from '../../src/common/tokenHelpers' +import { thorchainrune } from '../../src/cosmos/info/thorchainruneInfo' +import { thorchainrunestagenet } from '../../src/cosmos/info/thorchainruneStagenetInfo' +import { fakeLog } from '../fake/fakeLog' + +// The THORChain bank tokens Edge serves from the info server, +// as [currencyCode, raw denom, tokenId]: +const remoteTokens: Array<[string, string, string]> = [ + ['RUJI', 'x/ruji', 'xruji'], + ['AUTO', 'thor.auto', 'thor.auto'], + ['LQDY', 'thor.lqdy', 'thor.lqdy'], + ['BTC-BTC', 'btc-btc', 'btc-btc'], + ['ETH-ETH', 'eth-eth', 'eth-eth'], + ['SOL-SOL', 'sol-sol', 'sol-sol'], + ['sTCY', 'x/staking-tcy', 'xstaking-tcy'], + ['sRUJI', 'x/staking-x/ruji', 'xstaking-xruji'], + ['bRUNE', 'x/brune', 'xbrune'], + ['ybRUNE', 'x/staking-x/brune', 'xstaking-xbrune'] +] + +const IBC_DENOM = + 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2' + +const fakeIo = makeFakeIo() + +function makeToken(currencyCode: string, contractAddress?: unknown): EdgeToken { + return { + currencyCode, + displayName: currencyCode, + denominations: [{ name: currencyCode, multiplier: '100000000' }], + networkLocation: + contractAddress === undefined ? undefined : { contractAddress } + } +} + +function makePlugin( + infoServerTokens: unknown[] = [], + factory: typeof thorchainrune = thorchainrune +): EdgeCurrencyPlugin { + const infoPayload: JsonObject = { infoServerTokens } + return factory({ + infoPayload, + initOptions: {}, + io: fakeIo, + log: fakeLog, + nativeIo: {}, + pluginDisklet: fakeIo.disklet + }) +} + +async function getTokens(plugin: EdgeCurrencyPlugin): Promise { + return (await plugin.getBuiltinTokens?.()) ?? {} +} + +describe('createCosmosTokenId', function () { + it('maps bank denoms to token IDs', function () { + for (const [currencyCode, denom, tokenId] of remoteTokens) { + assert.equal( + createCosmosTokenId(makeToken(currencyCode, denom)), + tokenId, + denom + ) + } + }) + + it('accepts IBC and mixed-case denoms', function () { + assert.equal( + createCosmosTokenId(makeToken('ATOM', IBC_DENOM)), + IBC_DENOM.toLowerCase().replace('/', '') + ) + assert.equal( + createCosmosTokenId(makeToken('TEST', 'factory/osmo1abc/MyToken')), + 'factoryosmo1abcmytoken' + ) + }) + + it('rejects malformed denoms', function () { + const badDenoms: unknown[] = [ + undefined, + 42, + '', + 'xr', // Too short + '1abc', // Must start with a letter + 'ibc/abc', // Not a full IBC hash + 'x/ruji!', // Invalid character + 'x/ruji extra', + 'a'.repeat(129) // Too long + ] + for (const denom of badDenoms) { + assert.throws( + () => createCosmosTokenId(makeToken('TEST', denom)), + 'ErrorInvalidContractAddress', + String(denom) + ) + } + }) + + it('rejects an invalid currency code', function () { + assert.throws(() => createCosmosTokenId(makeToken(' RUJI', 'x/ruji'))) + }) +}) + +describe('THORChain info server tokens', function () { + // The plugin keeps its token map for the life of the process, + // so drop whatever each test added: + afterEach(async function () { + for (const factory of [thorchainrune, thorchainrunestagenet]) { + const tokens = await getTokens(makePlugin([], factory)) + for (const tokenId of Object.keys(tokens)) { + if (tokenId !== 'tcy') Reflect.deleteProperty(tokens, tokenId) + } + } + }) + + for (const [name, factory] of [ + ['thorchainrune', thorchainrune], + ['thorchainrunestagenet', thorchainrunestagenet] + ] as const) { + it(`${name} accepts remote tokens and keeps each raw denom`, async function () { + const plugin = makePlugin( + remoteTokens.map(([currencyCode, denom]) => + makeToken(currencyCode, denom) + ), + factory + ) + const tokens = await getTokens(plugin) + + assert.deepEqual( + Object.keys(tokens).sort((a, b) => a.localeCompare(b)), + ['tcy', ...remoteTokens.map(([, , tokenId]) => tokenId)].sort((a, b) => + a.localeCompare(b) + ) + ) + for (const [currencyCode, denom, tokenId] of remoteTokens) { + assert.equal(tokens[tokenId].currencyCode, currencyCode) + assert.deepEqual(tokens[tokenId].networkLocation, { + contractAddress: denom + }) + } + }) + } + + it('has only the bundled token without a payload', async function () { + assert.deepEqual(Object.keys(await getTokens(makePlugin())), ['tcy']) + }) + + it('keeps the bundled TCY over a remote definition', async function () { + const tokens = await getTokens( + makePlugin([ + { + ...makeToken('TCY', 'tcy'), + displayName: 'Remote TCY', + denominations: [{ name: 'TCY', multiplier: '1' }] + } + ]) + ) + assert.deepEqual(Object.keys(tokens), ['tcy']) + assert.equal(tokens.tcy.displayName, 'TCY') + assert.equal(tokens.tcy.denominations[0].multiplier, '100000000') + }) + + it('keeps the first of two remote tokens with the same token ID', async function () { + const tokens = await getTokens( + makePlugin([makeToken('RUJI', 'x/ruji'), makeToken('RUJI2', 'xruji')]) + ) + assert.equal(tokens.xruji.currencyCode, 'RUJI') + assert.deepEqual(tokens.xruji.networkLocation, { + contractAddress: 'x/ruji' + }) + }) + + it('rejects malformed and colliding entries without losing valid ones', async function () { + const tokens = await getTokens( + makePlugin([ + 'not a token', + { currencyCode: 'BAD' }, + makeToken('NODENOM'), + makeToken('SHORT', 'xr'), + makeToken('SPACE', 'x/ruji extra'), + makeToken('UPPER', 'X/RUJI'), + // The native asset is not a token: + makeToken('WRUNE', 'rune'), + // Currency codes already taken by RUNE and by the bundled TCY: + makeToken('RUNE', 'x/fakerune'), + makeToken('rune', 'x/fakerune2'), + makeToken('TCY', 'x/faketcy'), + makeToken('RUJI', 'x/ruji'), + // A second denom claiming an accepted remote token's code: + makeToken('RUJI', 'x/fakeruji'), + makeToken('AUTO', 'thor.auto') + ]) + ) + assert.deepEqual( + Object.keys(tokens).sort((a, b) => a.localeCompare(b)), + ['tcy', 'thor.auto', 'xruji'] + ) + }) + + it('accepts the same payload again', async function () { + const payload = [makeToken('RUJI', 'x/ruji')] + await getTokens(makePlugin(payload)) + const tokens = await getTokens(makePlugin(payload)) + assert.deepEqual( + Object.keys(tokens).sort((a, b) => a.localeCompare(b)), + ['tcy', 'xruji'] + ) + }) + + it('accepts tokens from a payload update', async function () { + this.timeout(30000) + const plugin = makePlugin() + // Updates are ignored until the inner plugin loads: + const tools = await plugin.makeCurrencyTools() + await plugin.updateInfoPayload?.({ + infoServerTokens: [makeToken('LQDY', 'thor.lqdy')] + }) + + const tokens = await getTokens(plugin) + assert.deepEqual(tokens['thor.lqdy'].networkLocation, { + contractAddress: 'thor.lqdy' + }) + assert.equal(await tools.getTokenId?.(tokens['thor.lqdy']), 'thor.lqdy') + }) +}) From 33140f207fea8d66da59e668bd6e1931a97664c9 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 1 Oct 2026 21:22:03 -0700 Subject: [PATCH 2/2] Map Midgard asset names to THORChain denoms Midgard names a token by its asset (THOR.RUJI, THOR.AUTO, X/RUJI), not by the bank denom the engine tracks (x/ruji, thor.auto). The shared mapping cut the name down to its last part, which matched nothing, and coin parsing rejected a denom with a dot, so the whole action was dropped from history. Port thornode's Asset.Native() as the THORChain mapping, keep the existing one for MAYAChain, and parse the full cosmos-sdk denom charset. --- CHANGELOG.md | 4 + src/cosmos/cosmosUtils.ts | 7 +- src/cosmos/engine/MidgardEngine.ts | 35 +++- src/cosmos/engine/ThorchainEngine.ts | 25 +++ test/cosmos/midgardActionToCoinEvents.test.ts | 193 ++++++++++++++++++ test/utils/parseCoins.test.ts | 8 + 6 files changed, 262 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e34d0777..6433026c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- added: (THORChain) Support tokens defined on the info server +- fixed: (THORChain) Token transactions missing from history +- fixed: (Cosmos) A chain's native denom can no longer be added as a custom token + ## 4.100.0 (2026-10-07) - changed: (Piratechain) Download the Sapling spend parameters on Android instead of bundling them diff --git a/src/cosmos/cosmosUtils.ts b/src/cosmos/cosmosUtils.ts index 6b0eefd13..faaf7a7eb 100644 --- a/src/cosmos/cosmosUtils.ts +++ b/src/cosmos/cosmosUtils.ts @@ -294,15 +294,16 @@ export const coerceToBytes = (val: any): Uint8Array | undefined => { return undefined } -// The helper function parseCoins from the @cosmjs sdk doesn't handle denoms with hyphens. +// The helper function parseCoins from the @cosmjs sdk only handles +// alphanumeric denoms, but real ones look like "x/ruji" or "thor.auto". export const extendedParseCoins = (input: string): Coin[] => { return input .replace(/\s/g, '') .split(',') .filter(Boolean) .map(part => { - // Denom regex from Stargate (https://github.com/cosmos/cosmos-sdk/blob/v0.42.7/types/coin.go#L599-L601) - const match = part.match(/^([0-9]+)([a-zA-Z][a-zA-Z0-9/-]{2,127})$/) + // Denom regex from the cosmos-sdk (https://github.com/cosmos/cosmos-sdk/blob/v0.50.0/types/coin.go#L865-L867) + const match = part.match(/^([0-9]+)([a-zA-Z][a-zA-Z0-9/:._-]{2,127})$/) if (match == null) throw new Error(`Got an invalid coin string: ${input}`) return { amount: match[1].replace(/^0+/, '') ?? '0', diff --git a/src/cosmos/engine/MidgardEngine.ts b/src/cosmos/engine/MidgardEngine.ts index 74d2b5b14..18c75cee0 100644 --- a/src/cosmos/engine/MidgardEngine.ts +++ b/src/cosmos/engine/MidgardEngine.ts @@ -50,6 +50,18 @@ export function isFailedMidgardAction(action: MidgardActionResponse): boolean { return action.status === 'failed' || action.type === 'failed' } +/** + * Turns a Midgard asset name into a bank denom. + * + * The asset might have a chain prefix like "MAYA.CACAO", + * or it might be a plain currency code like "TCY". + */ +export function midgardAssetToDenom(asset: string): string { + const assetParts = asset.split('.') + const assetCode = assetParts[1] ?? assetParts[0] + return assetCode.toLowerCase() +} + /** * Converts a single Midgard action into the coin_spent/coin_received events * used to compute our wallet's net balance change. @@ -66,11 +78,15 @@ export function isFailedMidgardAction(action: MidgardActionResponse): boolean { * A `type: 'failed'` deposit reports no networkFees in its metadata, so * callers pass the chain's standard fee as `fallbackNetworkFees` to keep the * burned fee on record. + * + * Midgard names assets its own way, so `assetToDenom` turns each name back + * into the bank denom the engine knows. */ export function midgardActionToCoinEvents( action: MidgardActionResponse, ourAddress: string, - fallbackNetworkFees: Array<{ amount: string; asset: string }> = [] + fallbackNetworkFees: Array<{ amount: string; asset: string }> = [], + assetToDenom: (asset: string) => string = midgardAssetToDenom ): Event[] { const events: Event[] = [] const pushCoinEvent = ( @@ -79,15 +95,11 @@ export function midgardActionToCoinEvents( amount: string, type: 'coin_spent' | 'coin_received' ): void => { - // The coin might have a prefix like "RUNE.RUNE" or "MAYA.CACAO", - // or it might be a plain currency code like "TCY": - const assetParts = asset.split('.') - const assetCode = assetParts[1] ?? assetParts[0] const typeValue = type === 'coin_received' ? 'receiver' : 'spender' events.push({ type, attributes: [ - { key: 'amount', value: `${abs(amount)}${assetCode.toLowerCase()}` }, + { key: 'amount', value: `${abs(amount)}${assetToDenom(asset)}` }, { key: typeValue, value: address } ] }) @@ -225,7 +237,8 @@ export class MidgardEngine extends CosmosEngine { const events = midgardActionToCoinEvents( action, ourAddress, - fallbackNetworkFees + fallbackNetworkFees, + asset => this.midgardAssetToDenom(asset) ) if (txidHex === mostRecentTxId) { @@ -293,6 +306,14 @@ export class MidgardEngine extends CosmosEngine { } } + /** + * Turns a Midgard asset name into a bank denom. + * Subclasses can override this for chain-specific asset names. + */ + protected midgardAssetToDenom(asset: string): string { + return midgardAssetToDenom(asset) + } + /** * Returns the fee to use for Midgard transactions. * Subclasses can override this for chain-specific fee estimation. diff --git a/src/cosmos/engine/ThorchainEngine.ts b/src/cosmos/engine/ThorchainEngine.ts index 693c725dc..9c682066f 100644 --- a/src/cosmos/engine/ThorchainEngine.ts +++ b/src/cosmos/engine/ThorchainEngine.ts @@ -11,6 +11,27 @@ import { MidgardNetworkInfo } from '../midgardTypes' import { asThornodeNetwork } from '../thorchainTypes' import { MidgardEngine } from './MidgardEngine' +/** + * Turns a THORChain asset name, as Midgard reports it, into a bank denom. + * + * This follows thornode's `Asset.Native()`: a few assets have their own + * denoms, and every other one uses its lowercased name. Midgard reports the + * same token under more than one name ("THOR.RUJI" in a swap, "X/RUJI" in a + * send), and both must land on the one denom the bank module uses. + */ +export function thorchainAssetToDenom(asset: string): string { + const assetName = asset.toUpperCase() + switch (assetName) { + case 'THOR.RUNE': + return 'rune' + case 'THOR.TCY': + return 'tcy' + case 'THOR.RUJI': + return 'x/ruji' + } + return assetName.toLowerCase() +} + /** * Thorchain-specific engine that uses the thornode API for fee calculation. */ @@ -46,6 +67,10 @@ export class ThorchainEngine extends MidgardEngine { } } + protected midgardAssetToDenom(asset: string): string { + return thorchainAssetToDenom(asset) + } + async calculateFee(opts: { messages: EncodeObject[] }): Promise { const { url, headers } = rpcWithApiKey( this.networkInfo.transactionFeeConnectionInfo, diff --git a/test/cosmos/midgardActionToCoinEvents.test.ts b/test/cosmos/midgardActionToCoinEvents.test.ts index ab32d22a0..1b3534d91 100644 --- a/test/cosmos/midgardActionToCoinEvents.test.ts +++ b/test/cosmos/midgardActionToCoinEvents.test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'mocha' import { reduceCoinEventsForAddress } from '../../src/cosmos/cosmosUtils' import { midgardActionToCoinEvents } from '../../src/cosmos/engine/MidgardEngine' +import { thorchainAssetToDenom } from '../../src/cosmos/engine/ThorchainEngine' import { asMidgardActionResponse, MidgardActionResponse @@ -167,3 +168,195 @@ describe('midgardActionToCoinEvents', function () { assert.deepEqual(events, []) }) }) + +const RUJI_SENDER = 'thor1qjzfutd45uxczjakm5m69ytm63pua0qyh0dhch' +const RUJI_RECIPIENT = 'thor1v8ppstuf6e3x0r4glqc68d5jqcs2tf38cg2q6y' + +// A real THORChain token send, where Midgard names RUJI "X/RUJI" and the +// secured USDT it swaps into "ETH-USDT-0X...". +// Source: thorchain Midgard actions API, txid FBB394...D08839, verbatim. +const rujiSend = asMidgardActionResponse({ + date: '1790907697419049955', + height: '28065109', + in: [ + { + address: RUJI_SENDER, + coins: [{ amount: '7136000000', asset: 'X/RUJI' }], + txID: 'FBB39400452DA7FB636EE7057F49255E7576B09C115A30BC284AD61BAED08839' + } + ], + metadata: { + send: { + code: '0', + memo: '=:ETH-USDT:thor1qjzfutd45uxczjakm5m69ytm63pua0qyh0dhch:1495487238', + networkFees: [{ amount: '20000000', asset: 'X/RUJI' }], + reason: '' + } + }, + out: [ + { + address: RUJI_RECIPIENT, + coins: [{ amount: '7136000000', asset: 'X/RUJI' }], + txID: 'FBB39400452DA7FB636EE7057F49255E7576B09C115A30BC284AD61BAED08839' + }, + { + address: RUJI_SENDER, + coins: [ + { + amount: '1525160700', + asset: 'ETH-USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7' + } + ], + txID: 'FBB39400452DA7FB636EE7057F49255E7576B09C115A30BC284AD61BAED08839' + } + ], + pools: [], + status: 'success', + type: 'send' +}) + +const RUJI_BUYER = 'thor14c90cu0j65pjygsmdu2fsjlfawwm46cl4qp4er' + +// A real swap into the same token, which Midgard names "THOR.RUJI" here. +// Source: thorchain Midgard actions API, txid CAD5DB...8EE4DA, verbatim. +const rujiSwap = asMidgardActionResponse({ + date: '1790914179747877085', + height: '28066166', + in: [ + { + address: RUJI_BUYER, + coins: [{ amount: '28000000', asset: 'THOR.RUNE' }], + txID: 'CAD5DBA534D91C6E397FBF908DCAFAC057DEB3DF8D87FE7EF24D65B3528EE4DA' + } + ], + metadata: { + swap: { + memo: '=:THOR.RUJI:thor14c90cu0j65pjygsmdu2fsjlfawwm46cl4qp4er:99392017/0/1:w1:0', + networkFees: [] + } + }, + out: [ + { + address: RUJI_BUYER, + coins: [{ amount: '102465997', asset: 'THOR.RUJI' }], + txID: '' + } + ], + pools: ['THOR.RUJI'], + status: 'success', + type: 'swap' +}) + +const AUTO_SENDER = 'thor1n5u0dt8mdj6dua7l4pgksv52zw58wqkyn60ufx' +const AUTO_RECIPIENT = 'thor10a24usyt30rxll566wpfvw6tqnnurh8zqzyvfg' + +// A real send of a token whose denom has a dot in it. +// Source: thorchain Midgard actions API, txid E6F890...852146, verbatim. +const autoSend = asMidgardActionResponse({ + date: '1788018137399630360', + height: '27608244', + in: [ + { + address: AUTO_SENDER, + coins: [{ amount: '2753266246496', asset: 'THOR.AUTO' }], + txID: 'E6F8903506502466028CC9A3F397B3CF066E3C7B08F88D13C742289CA3852146' + } + ], + metadata: { + send: { + code: '0', + memo: '', + networkFees: [{ amount: '20000000', asset: 'THOR.AUTO' }], + reason: '' + } + }, + out: [ + { + address: AUTO_RECIPIENT, + coins: [{ amount: '2753266246496', asset: 'THOR.AUTO' }], + txID: 'E6F8903506502466028CC9A3F397B3CF066E3C7B08F88D13C742289CA3852146' + } + ], + pools: [], + status: 'success', + type: 'send' +}) + +const thorChanges = ( + action: MidgardActionResponse, + address: string +): Array<{ amount: string; denom: string }> => + reduceCoinEventsForAddress( + midgardActionToCoinEvents( + action, + address, + THOR_FALLBACK_FEES, + thorchainAssetToDenom + ), + address + ) + +describe('thorchainAssetToDenom', function () { + it('maps every Midgard name for a token to its bank denom', function () { + const cases: Array<[string, string]> = [ + ['THOR.RUNE', 'rune'], + ['rune', 'rune'], + ['THOR.TCY', 'tcy'], + ['TCY', 'tcy'], + ['THOR.RUJI', 'x/ruji'], + ['X/RUJI', 'x/ruji'], + ['THOR.AUTO', 'thor.auto'], + ['THOR.LQDY', 'thor.lqdy'], + ['BTC-BTC', 'btc-btc'], + ['ETH-ETH', 'eth-eth'], + ['SOL-SOL', 'sol-sol'], + ['X/STAKING-TCY', 'x/staking-tcy'], + ['X/STAKING-X/RUJI', 'x/staking-x/ruji'], + ['X/BRUNE', 'x/brune'], + ['X/STAKING-X/BRUNE', 'x/staking-x/brune'] + ] + for (const [asset, denom] of cases) { + assert.equal(thorchainAssetToDenom(asset), denom, asset) + } + }) +}) + +describe('midgardActionToCoinEvents on THORChain tokens', function () { + it('records a token send named by its raw denom', function () { + assert.deepEqual(thorChanges(rujiSend, RUJI_RECIPIENT), [ + { denom: 'x/ruji', amount: '7136000000' } + ]) + assert.deepEqual(thorChanges(rujiSend, RUJI_SENDER), [ + { denom: 'x/ruji', amount: '-7136000000' }, + { + denom: 'eth-usdt-0xdac17f958d2ee523a2206206994597c13d831ec7', + amount: '1525160700' + } + ]) + }) + + it('records a swap into a token named with a chain prefix', function () { + assert.deepEqual(thorChanges(rujiSwap, RUJI_BUYER), [ + { denom: 'rune', amount: '-28000000' }, + { denom: 'x/ruji', amount: '102465997' } + ]) + }) + + it('records a token whose denom has a dot in it', function () { + assert.deepEqual(thorChanges(autoSend, AUTO_SENDER), [ + { denom: 'thor.auto', amount: '-2753266246496' } + ]) + assert.deepEqual(thorChanges(autoSend, AUTO_RECIPIENT), [ + { denom: 'thor.auto', amount: '2753266246496' } + ]) + }) + + it('still strips the chain prefix by default', function () { + // MAYAChain keeps the plain mapping, so a prefixed name is cut down to + // its code rather than treated as a denom: + const events = midgardActionToCoinEvents(successfulSend, RECIPIENT) + assert.deepEqual(reduceCoinEventsForAddress(events, RECIPIENT), [ + { denom: 'cacao', amount: '12128369999999' } + ]) + }) +}) diff --git a/test/utils/parseCoins.test.ts b/test/utils/parseCoins.test.ts index 44ca50e3e..5c8ec7276 100644 --- a/test/utils/parseCoins.test.ts +++ b/test/utils/parseCoins.test.ts @@ -21,6 +21,14 @@ describe(`Cosmos parseCoins test`, function () { } ] ) + assert.deepEqual( + extendedParseCoins('5thor.auto,6x/staking-x/ruji,7btc-btc'), + [ + { amount: '5', denom: 'thor.auto' }, + { amount: '6', denom: 'x/staking-x/ruji' }, + { amount: '7', denom: 'btc-btc' } + ] + ) assert.deepEqual(extendedParseCoins('1337uwat'), [ { denom: 'uwat',