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/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/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/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/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/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') + }) +}) 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',