diff --git a/CHANGELOG.md b/CHANGELOG.md index 21322ce2..04114e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- added: `EdgeCurrencyConfig.encodePayLink` +- added: `EdgeCurrencyConfig.parseLink` +- deprecated: `EdgeCurrencyWallet.encodeUri`. Use `EdgeCurrencyConfig.encodePayLink` +- deprecated: `EdgeCurrencyWallet.parseUri`. Use `EdgeCurrencyConfig.parseLink` + ## 2.38.4 (2026-01-12) - fixed: Allow duplicate sync keys when performing a wallet split, in case a previous failed attempt left a repo behind. diff --git a/src/core/account/plugin-api.ts b/src/core/account/plugin-api.ts index 7bff1070..0724ec5a 100644 --- a/src/core/account/plugin-api.ts +++ b/src/core/account/plugin-api.ts @@ -5,16 +5,20 @@ import { EdgeCurrencyInfo, EdgeGetTokenDetailsFilter, EdgeOtherMethods, + EdgeParsedLink, + EdgePayLink, EdgeSwapConfig, EdgeSwapInfo, EdgeToken, + EdgeTokenId, EdgeTokenMap } from '../../types/types' +import { parsedUriToLink } from '../currency/uri-tools' import { uniqueStrings } from '../currency/wallet/enabled-tokens' import { getCurrencyTools } from '../plugins/plugins-selectors' import { ApiInput } from '../root-pixie' import { changePluginUserSettings, changeSwapSettings } from './account-files' -import { getTokenId } from './custom-tokens' +import { getTokenId, makeMetaTokens } from './custom-tokens' const emptyTokens: EdgeTokenMap = {} const emptyTokenIds: string[] = [] @@ -186,6 +190,54 @@ export class CurrencyConfig ) } + async parseLink( + link: string, + opts: { tokenId?: EdgeTokenId } = {} + ): Promise { + const { tokenId } = opts + const { allTokens } = this + const tools = await getCurrencyTools(this._ai, this._pluginId) + + if (tools.parseLink != null) { + return await tools.parseLink(link, { tokenId, allTokens }) + } + + // Fallback version: + if (tools.parseUri != null) { + const out = await tools.parseUri( + link, + this.downgradeTokenId(tokenId), + makeMetaTokens(this.customTokens) + ) + return parsedUriToLink(out, this.currencyInfo, allTokens) + } + + return {} + } + + async encodePayLink(link: EdgePayLink): Promise { + const { tokenId } = link + const { allTokens } = this + const tools = await getCurrencyTools(this._ai, this._pluginId) + + if (tools.encodePayLink != null) { + return await tools.encodePayLink(link, { allTokens }) + } + + // Fallback version: + if (tools.encodeUri != null) { + return await tools.encodeUri( + { + ...link, + currencyCode: this.downgradeTokenId(tokenId) + }, + makeMetaTokens(this.customTokens) + ) + } + + return '' + } + async importKey( userInput: string, opts: { keyOptions?: object } = {} @@ -198,6 +250,13 @@ export class CurrencyConfig const keys = await tools.importPrivateKey(userInput, opts.keyOptions) return { ...keys, imported: true } } + + private downgradeTokenId(tokenId?: EdgeTokenId): string | undefined { + if (tokenId === undefined) return + return tokenId == null + ? this.currencyInfo.currencyCode + : this.allTokens[tokenId]?.currencyCode + } } export class SwapConfig extends Bridgeable { diff --git a/src/core/currency/uri-tools.ts b/src/core/currency/uri-tools.ts new file mode 100644 index 00000000..2d8f5211 --- /dev/null +++ b/src/core/currency/uri-tools.ts @@ -0,0 +1,170 @@ +import { + EdgeCurrencyInfo, + EdgeParsedLink, + EdgeParsedUri, + EdgeTokenMap +} from '../../types/types' +import { makeMetaToken } from '../account/custom-tokens' + +export function parsedUriToLink( + uri: EdgeParsedUri, + currencyInfo: EdgeCurrencyInfo, + allTokens: EdgeTokenMap +): EdgeParsedLink { + const { + // Edge has never supported BitID: + // bitIDCallbackUri, + // bitIDDomain, + // bitidKycProvider, // Experimental + // bitidKycRequest, // Experimental + // bitidPaymentAddress, // Experimental + // bitIDURI, + + // The GUI handles address requests: + // returnUri, + + currencyCode, + expireDate, + legacyAddress, + metadata, + minNativeAmount, + nativeAmount, + paymentProtocolUrl, + privateKeys, + publicAddress, + segwitAddress, + token, + uniqueIdentifier, + walletConnect + } = uri + let { tokenId } = uri + + if (tokenId === undefined && currencyCode != null) { + tokenId = + currencyCode === currencyInfo.currencyCode + ? null + : Object.keys(allTokens).find( + tokenId => allTokens[tokenId].currencyCode === currencyCode + ) + } + + const out: EdgeParsedLink = {} + + // Payment addresses: + const payAddress = legacyAddress ?? publicAddress ?? segwitAddress + if (payAddress != null) { + out.pay = { + publicAddress: payAddress, + addressType: + legacyAddress != null + ? 'legacyAddress' + : publicAddress != null + ? 'publicAddress' + : 'segwitAddress', + label: metadata?.name, + message: metadata?.notes, + memo: uniqueIdentifier, + memoType: 'text', + nativeAmount: nativeAmount, + minNativeAmount: minNativeAmount, + tokenId: tokenId, + expires: expireDate, + // Plugins marked RenBridge Gateway addresses using this + // undocumented field: + isGateway: (metadata as any)?.gateway + } + } + + if (paymentProtocolUrl != null) { + out.paymentProtocol = { paymentProtocolUrl } + } + + // Private keys: + if (privateKeys != null && privateKeys.length > 0) { + out.privateKey = { privateKey: privateKeys[0] } + } + + // Custom tokens: + if (token != null) { + const { contractAddress, currencyCode, currencyName, denominations } = token + out.token = { + currencyCode, + denominations, + displayName: currencyName, + networkLocation: { + contractAddress, + // The edge-currency-accountbased custom token parser would + // insert this undocumented field into `EdgeMetaToken`. + // We can preserve this information in `networkLocation`, + // which is a free-form field designed to hold info like this: + type: (token as any).type + } + } + } + + if (walletConnect != null) { + out.walletConnect = walletConnect + } + + return out +} + +export function linkToParsedUri(link: EdgeParsedLink): EdgeParsedUri { + const out: EdgeParsedUri = {} + + // Payment addresses: + if (link.pay != null) { + const { + publicAddress, + addressType, + label, + message, + memo, + nativeAmount, + minNativeAmount, + tokenId, + expires, + isGateway + } = link.pay + out.publicAddress = publicAddress + if (addressType === 'legacyAddress') out.legacyAddress = publicAddress + if (addressType === 'segwitAddress') out.segwitAddress = publicAddress + out.metadata = { + name: label, + notes: message, + // @ts-expect-error Undocumented feature: + gateway: isGateway + } + out.uniqueIdentifier = memo + out.nativeAmount = nativeAmount + out.minNativeAmount = minNativeAmount + out.tokenId = tokenId + out.expireDate = expires + ;(out as any).gateway = isGateway + } + + // Payment protocol: + if (link.paymentProtocol != null) { + const { paymentProtocolUrl } = link.paymentProtocol + out.paymentProtocolUrl = paymentProtocolUrl + } + + // Private keys: + if (link.privateKey != null) { + const { privateKey } = link.privateKey + out.privateKeys = [privateKey] + } + + // Custom tokens: + if (link.token != null) { + out.token = makeMetaToken(link.token) + // @ts-expect-error Undocumented "ERC20" field: + out.token.type = link.token.networkLocation.type + } + + if (link.walletConnect != null) { + out.walletConnect = link.walletConnect + } + + return out +} diff --git a/src/core/currency/wallet/currency-wallet-api.ts b/src/core/currency/wallet/currency-wallet-api.ts index 55a9f02b..ea66e932 100644 --- a/src/core/currency/wallet/currency-wallet-api.ts +++ b/src/core/currency/wallet/currency-wallet-api.ts @@ -43,6 +43,7 @@ import { makeMetaTokens } from '../../account/custom-tokens' import { toApiInput } from '../../root-pixie' import { makeStorageWalletApi } from '../../storage/storage-api' import { getCurrencyMultiplier } from '../currency-selectors' +import { linkToParsedUri } from '../uri-tools' import { makeCurrencyWalletCallbacks } from './currency-wallet-callbacks' import { asEdgeAssetAction, @@ -724,31 +725,68 @@ export function makeCurrencyWalletApi( // URI handling: async encodeUri(options: EdgeEncodeUri): Promise { - return await tools.encodeUri( - options, - makeMetaTokens( - input.props.state.accounts[accountId].customTokens[pluginId] + const allTokens = + input.props.state.accounts[accountId].allTokens[pluginId] + + if (tools.encodePayLink != null) { + const { tokenId = null } = upgradeCurrencyCode({ + allTokens, + currencyInfo: plugin.currencyInfo, + currencyCode: options.currencyCode + }) + return await tools.encodePayLink( + { ...options, addressType: 'publicAddress', tokenId }, + { allTokens } ) - ) + } + + if (tools.encodeUri != null) { + return await tools.encodeUri( + options, + makeMetaTokens( + input.props.state.accounts[accountId].customTokens[pluginId] + ) + ) + } + + return '' }, + async parseUri(uri: string, currencyCode?: string): Promise { - const parsedUri = await tools.parseUri( - uri, - currencyCode, - makeMetaTokens( - input.props.state.accounts[accountId].customTokens[pluginId] - ) - ) + const allTokens = + input.props.state.accounts[accountId].allTokens[pluginId] - if (parsedUri.tokenId === undefined) { + if (tools.parseLink != null) { const { tokenId = null } = upgradeCurrencyCode({ - allTokens: input.props.state.accounts[accountId].allTokens[pluginId], + allTokens, currencyInfo: plugin.currencyInfo, - currencyCode: parsedUri.currencyCode ?? currencyCode + currencyCode }) - parsedUri.tokenId = tokenId + const out = await tools.parseLink(uri, { allTokens, tokenId }) + return linkToParsedUri(out) + } + + if (tools.parseUri != null) { + const parsedUri = await tools.parseUri( + uri, + currencyCode, + makeMetaTokens( + input.props.state.accounts[accountId].customTokens[pluginId] + ) + ) + + if (parsedUri.tokenId === undefined) { + const { tokenId = null } = upgradeCurrencyCode({ + allTokens, + currencyInfo: plugin.currencyInfo, + currencyCode: parsedUri.currencyCode ?? currencyCode + }) + parsedUri.tokenId = tokenId + } + return parsedUri } - return parsedUri + + return {} }, // Generic: diff --git a/src/types/types.ts b/src/types/types.ts index 1e370362..2806be68 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -856,6 +856,64 @@ export interface EdgeEncodeUri { currencyCode?: string } +/** + * A payment address, with optional metadata. + */ +export interface EdgePayLink { + publicAddress: string + + /** Same meaning as EdgeAddress.addressType */ + addressType: string + + /** Recipient name */ + label?: string + + /** Transaction note */ + message?: string + + /** On-chain memo */ + memo?: string + memoType?: 'text' | 'number' | 'hex' // EdgeMemoOption['type'] + + /** Amount to send */ + nativeAmount?: string + minNativeAmount?: string + + /** What to send, specifically */ + tokenId?: EdgeTokenId + + /** If the address will go away */ + expires?: Date + + /** True if this is a Renproject Gateway URI */ + isGateway?: boolean +} + +export interface EdgePaymentProtocolLink { + paymentProtocolUrl: string +} + +/** + * A private key, with the power of spending. + */ +export interface EdgePrivateKeyLink { + privateKey: string +} + +/** + * A parsed link can have multiple meanings, + * so returns whatever interpretations make sense. + * For instance, an EVM address can be both a payment request + * and a view key. + */ +export interface EdgeParsedLink { + pay?: EdgePayLink + paymentProtocol?: EdgePaymentProtocolLink + privateKey?: EdgePrivateKeyLink + token?: EdgeToken + walletConnect?: WalletConnect +} + // options ------------------------------------------------------------- export interface EdgeTokenIdOptions { @@ -1187,12 +1245,25 @@ export interface EdgeCurrencyTools { readonly getTokenId?: (token: EdgeToken) => Promise // URIs: - readonly parseUri: ( + readonly parseLink: ( + link: string, + opts: { allTokens: EdgeTokenMap; tokenId?: EdgeTokenId } + ) => Promise + + readonly encodePayLink: ( + link: EdgePayLink, + opts: { allTokens: EdgeTokenMap } + ) => Promise + + /** @deprecated Provide encodeLink instead */ + readonly parseUri?: ( uri: string, currencyCode?: string, customTokens?: EdgeMetaToken[] ) => Promise - readonly encodeUri: ( + + /** @deprecated Provide encodeLink instead */ + readonly encodeUri?: ( obj: EdgeEncodeUri, customTokens?: EdgeMetaToken[] ) => Promise @@ -1385,16 +1456,18 @@ export interface EdgeCurrencyWallet { readonly dumpData: () => Promise readonly resyncBlockchain: () => Promise - // URI handling: + // Generic: + readonly otherMethods: EdgeOtherMethods + + /** @deprecated Use EdgeCurrencyConfig.encodePayLink instead */ readonly encodeUri: (obj: EdgeEncodeUri) => Promise + + /** @deprecated Use EdgeCurrencyConfig.parseLink instead */ readonly parseUri: ( uri: string, currencyCode?: string ) => Promise - // Generic: - readonly otherMethods: EdgeOtherMethods - /** @deprecated Use the information in EdgeCurrencyInfo / EdgeToken. */ readonly denominationToNative: ( denominatedAmount: string, @@ -1662,6 +1735,13 @@ export interface EdgeCurrencyConfig { readonly userSettings: JsonObject | undefined readonly changeUserSettings: (settings: JsonObject) => Promise + // URI handling: + readonly parseLink: ( + link: string, + opts?: { tokenId?: EdgeTokenId } + ) => Promise + readonly encodePayLink: (link: EdgePayLink) => Promise + // Utility methods: readonly importKey: ( userInput: string, diff --git a/test/core/currency/uri-tools.test.ts b/test/core/currency/uri-tools.test.ts new file mode 100644 index 00000000..17e1754a --- /dev/null +++ b/test/core/currency/uri-tools.test.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { + linkToParsedUri, + parsedUriToLink +} from '../../../src/core/currency/uri-tools' +import { EdgeParsedLink, EdgeParsedUri } from '../../../src/types/types' +import { fakeCurrencyInfo, fakeTokens } from '../../fake/fake-currency-plugin' + +describe('uri tools', function () { + it('converts a parsed uri into a link', async function () { + const metadata = { + name: 'Alice', + notes: 'Pay request', + gateway: true + } + const uriToken = { + contractAddress: '0x1234', + currencyCode: 'CSTM', + currencyName: 'Custom Token', + denominations: [{ multiplier: '1', name: 'CSTM' }], + type: 'erc20' + } + const expireDate = new Date('2024-01-01T00:00:00Z') + + const uri: EdgeParsedUri = { + currencyCode: 'TOKEN', + publicAddress: 'fakeaddress', + metadata, + uniqueIdentifier: 'memo123', + nativeAmount: '100', + minNativeAmount: '50', + expireDate, + paymentProtocolUrl: 'https://example.com/pay', + privateKeys: ['privkey'], + token: uriToken, + walletConnect: { + uri: 'wc:abc', + topic: 'topic' + } + } + + const link = parsedUriToLink(uri, fakeCurrencyInfo, fakeTokens) + + // Payment request: + expect(link.pay?.publicAddress).equals('fakeaddress') + expect(link.pay?.addressType).equals('publicAddress') + expect(link.pay?.label).equals('Alice') + expect(link.pay?.message).equals('Pay request') + expect(link.pay?.memo).equals('memo123') + expect(link.pay?.memoType).equals('text') + expect(link.pay?.nativeAmount).equals('100') + expect(link.pay?.minNativeAmount).equals('50') + expect(link.pay?.tokenId).equals('badf00d5') + expect(link.pay?.expires?.valueOf()).equals(expireDate.valueOf()) + expect(link.pay?.isGateway).equals(true) + + // Payment protocol: + expect(link.paymentProtocol?.paymentProtocolUrl).equals( + 'https://example.com/pay' + ) + + // Private key: + expect(link.privateKey?.privateKey).equals('privkey') + + // Custom token: + expect(link.token?.currencyCode).equals('CSTM') + expect(link.token?.displayName).equals('Custom Token') + const networkLocation: any = link.token?.networkLocation + expect(networkLocation.contractAddress).equals('0x1234') + expect(networkLocation.type).equals('erc20') + + // WalletConnect: + expect(link.walletConnect).deep.equals(uri.walletConnect) + }) + + it('converts a parsed link into a uri', function () { + const expires = new Date('2024-02-02T00:00:00Z') + const link: EdgeParsedLink = { + pay: { + publicAddress: 'segwitaddress', + addressType: 'segwitAddress', + label: 'Bob', + message: 'Request', + memo: 'memo321', + memoType: 'text', + nativeAmount: '5', + minNativeAmount: '1', + tokenId: 'badf00d5', + expires, + isGateway: true + }, + paymentProtocol: { + paymentProtocolUrl: 'https://example.com/protocol' + }, + privateKey: { privateKey: 'otherkey' }, + token: { + currencyCode: 'CSTM', + denominations: [{ multiplier: '1', name: 'CSTM' }], + displayName: 'Custom Token', + networkLocation: { contractAddress: '0x1234', type: 'erc20' } + }, + walletConnect: { + uri: 'wc:def', + topic: 'topic2', + version: '2' + } + } + + const uri = linkToParsedUri(link) + expect(uri.publicAddress).equals('segwitaddress') + expect(uri.segwitAddress).equals('segwitaddress') + expect(uri.legacyAddress).equals(undefined) + expect(uri.metadata?.name).equals('Bob') + expect(uri.metadata?.notes).equals('Request') + expect((uri.metadata as any)?.gateway).equals(true) + expect(uri.uniqueIdentifier).equals('memo321') + expect(uri.nativeAmount).equals('5') + expect(uri.minNativeAmount).equals('1') + expect(uri.tokenId).equals('badf00d5') + expect(uri.expireDate?.getTime()).equals(expires.getTime()) + expect(uri.paymentProtocolUrl).equals('https://example.com/protocol') + expect(uri.privateKeys).deep.equals(['otherkey']) + expect(uri.token?.currencyCode).equals('CSTM') + expect(uri.token?.currencyName).equals('Custom Token') + expect(uri.token?.contractAddress).equals('0x1234') + expect((uri.token as any)?.type).equals('erc20') + expect(uri.walletConnect).deep.equals(link.walletConnect) + }) +}) diff --git a/test/fake/fake-broken-engine.ts b/test/fake/fake-broken-engine.ts index 96501506..709cf908 100644 --- a/test/fake/fake-broken-engine.ts +++ b/test/fake/fake-broken-engine.ts @@ -30,10 +30,10 @@ export const brokenEnginePlugin: EdgeCurrencyPlugin = { getSplittableTypes() { return Promise.resolve([]) }, - parseUri() { + parseLink() { return Promise.resolve({}) }, - encodeUri() { + encodePayLink() { return Promise.resolve('') } } diff --git a/test/fake/fake-currency-plugin.ts b/test/fake/fake-currency-plugin.ts index 882436d8..0053f01b 100644 --- a/test/fake/fake-currency-plugin.ts +++ b/test/fake/fake-currency-plugin.ts @@ -12,7 +12,7 @@ import { EdgeFreshAddress, EdgeGetReceiveAddressOptions, EdgeGetTransactionsOptions, - EdgeParsedUri, + EdgeParsedLink, EdgeSpendInfo, EdgeStakingStatus, EdgeToken, @@ -27,7 +27,7 @@ import { upgradeCurrencyCode } from '../../src/types/type-helpers' const GENESIS_BLOCK = 1231006505 -const fakeTokens: EdgeTokenMap = { +export const fakeTokens: EdgeTokenMap = { badf00d5: { currencyCode: 'TOKEN', denominations: [{ multiplier: '1000', name: 'TOKEN' }], @@ -38,7 +38,7 @@ const fakeTokens: EdgeTokenMap = { } } -const fakeCurrencyInfo: EdgeCurrencyInfo = { +export const fakeCurrencyInfo: EdgeCurrencyInfo = { currencyCode: 'FAKE', displayName: 'Fake Coin', chainDisplayName: 'Fake Chain', @@ -367,11 +367,11 @@ class FakeCurrencyTools implements EdgeCurrencyTools { } // URI parsing: - parseUri(uri: string): Promise { + parseLink(uri: string): Promise { return Promise.resolve({}) } - encodeUri(): Promise { + encodePayLink(): Promise { return Promise.resolve('') } }