Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/common/tokenHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '')
}
26 changes: 12 additions & 14 deletions src/cosmos/CosmosTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -239,24 +242,19 @@ export class CosmosTools implements EdgeCurrencyTools {
}

async getTokenId(token: EdgeToken): Promise<string> {
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
}
}

Expand Down
7 changes: 4 additions & 3 deletions src/cosmos/cosmosUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 28 additions & 7 deletions src/cosmos/engine/MidgardEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = (
Expand All @@ -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 }
]
})
Expand Down Expand Up @@ -225,7 +237,8 @@ export class MidgardEngine extends CosmosEngine {
const events = midgardActionToCoinEvents(
action,
ourAddress,
fallbackNetworkFees
fallbackNetworkFees,
asset => this.midgardAssetToDenom(asset)
)

if (txidHex === mostRecentTxId) {
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions src/cosmos/engine/ThorchainEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -46,6 +67,10 @@ export class ThorchainEngine extends MidgardEngine {
}
}

protected midgardAssetToDenom(asset: string): string {
return thorchainAssetToDenom(asset)
}

async calculateFee(opts: { messages: EncodeObject[] }): Promise<CosmosFee> {
const { url, headers } = rpcWithApiKey(
this.networkInfo.transactionFeeConnectionInfo,
Expand Down
54 changes: 54 additions & 0 deletions src/cosmos/info/thorchainCommonInfo.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 6 additions & 0 deletions src/cosmos/info/thorchainruneInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/cosmos/info/thorchainruneStagenetInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading