diff --git a/modules/sdk-coin-xlm/src/xlm.ts b/modules/sdk-coin-xlm/src/xlm.ts index 9abda3a089..7d342e8c2e 100644 --- a/modules/sdk-coin-xlm/src/xlm.ts +++ b/modules/sdk-coin-xlm/src/xlm.ts @@ -1,15 +1,24 @@ import assert from 'assert'; +import { BigNumber } from 'bignumber.js'; import * as _ from 'lodash'; import * as querystring from 'querystring'; -import * as url from 'url'; -import * as request from 'superagent'; import * as stellar from 'stellar-sdk'; -import { BigNumber } from 'bignumber.js'; -import * as Utils from './lib/utils'; +import * as request from 'superagent'; +import * as url from 'url'; import { KeyPair as StellarKeyPair } from './lib/keyPair'; +import * as Utils from './lib/utils'; +import { toBitgoRequest } from '@bitgo/sdk-api'; import { + AuditDecryptedKeyParams, BaseCoin, + SignTransactionOptions as BaseSignTransactionOptions, + TransactionExplanation as BaseTransactionExplanation, + TransactionRecipient as BaseTransactionOutput, + TransactionParams as BaseTransactionParams, + TransactionPrebuild as BaseTransactionPrebuild, + VerifyAddressOptions as BaseVerifyAddressOptions, + VerifyTransactionOptions as BaseVerifyTransactionOptions, BitGoBase, checkKrsProvider, common, @@ -19,26 +28,17 @@ import { ITransactionRecipient, KeyIndices, KeyPair, + MultisigType, + multisigTypes, + NotSupported, ParsedTransaction, ParseTransactionOptions, promiseProps, - SignTransactionOptions as BaseSignTransactionOptions, StellarFederationUserNotFoundError, TokenEnablementConfig, - TransactionExplanation as BaseTransactionExplanation, - TransactionParams as BaseTransactionParams, - TransactionPrebuild as BaseTransactionPrebuild, - TransactionRecipient as BaseTransactionOutput, UnexpectedAddressError, - VerifyAddressOptions as BaseVerifyAddressOptions, - VerifyTransactionOptions as BaseVerifyTransactionOptions, Wallet, - NotSupported, - MultisigType, - multisigTypes, - AuditDecryptedKeyParams, } from '@bitgo/sdk-core'; -import { toBitgoRequest } from '@bitgo/sdk-api'; import { getStellarKeys } from './getStellarKeys'; /** @@ -996,35 +996,37 @@ export class Xlm extends BaseCoin { } as any; } - /** - * Verify that a tx prebuild's operations comply with the original intention - * @param {stellar.Operation} operations - tx operations - * @param {TransactionParams} txParams - params used to build the tx - */ - verifyEnableTokenTxOperations(operations: stellar.Operation[], txParams: TransactionParams): void { - const trustlineOperations = _.filter(operations, ['type', 'changeTrust']) as stellar.Operation.ChangeTrust[]; - if (trustlineOperations.length !== _.get(txParams, 'recipients', []).length) { + getTrustlineOperationsOrThrow( + operations: stellar.Operation[], + txParams: TransactionParams, + operationTypePropName: 'trustlines' | 'recipients' + ): stellar.Operation.ChangeTrust[] { + const trustlineOperations = operations.filter((op) => op?.type === 'changeTrust'); + if (trustlineOperations.length !== _.get(txParams, operationTypePropName, []).length) { throw new Error('transaction prebuild does not match expected trustline operations'); } - _.forEach(trustlineOperations, (op: stellar.Operation) => { - if (op.type !== 'changeTrust') { - throw new Error('Invalid asset type'); - } - if (op.line.getAssetType() === 'liquidity_pool_shares') { - throw new Error('Invalid asset type'); - } - const asset = op.line as stellar.Asset; - const opToken = this.getTokenNameFromStellarAsset(asset); - const tokenTrustline = _.find(txParams.recipients, (recipient) => { - // trustline params use limits in base units - const opLimitBaseUnits = this.bigUnitsToBaseUnits(op.limit); - // Enable token limit is set to Xlm.maxTrustlineLimit by default - return recipient.tokenName === opToken && opLimitBaseUnits === Xlm.maxTrustlineLimit; - }); - if (!tokenTrustline) { - throw new Error('transaction prebuild does not match expected trustline tokens'); - } - }); + + return trustlineOperations; + } + + isChangeTrustOperation(operation: stellar.Operation): operation is stellar.Operation.ChangeTrust { + return operation.type && operation.type === 'changeTrust'; + } + + getTrustlineOperationLineOrThrow(operation: stellar.Operation): stellar.Asset | stellar.LiquidityPoolAsset { + if (this.isChangeTrustOperation(operation) && operation.line) return operation.line; + throw new Error('Invalid operation - expected changeTrust operation with line property'); + } + + getTrustlineOperationLimitOrThrow(operation: stellar.Operation): string { + if (this.isChangeTrustOperation(operation) && operation.limit) return operation.limit; + throw new Error('Invalid operation - expected changeTrust operation with limit property'); + } + + isOperationLineOfAssetType(line: stellar.Asset | stellar.LiquidityPoolAsset): line is stellar.Asset { + // line should be stellar.Asset, we removed the explicit cast and check the type instead + if (!line.getAssetType) return false; + return line.getAssetType() !== 'liquidity_pool_shares'; } /** @@ -1033,10 +1035,7 @@ export class Xlm extends BaseCoin { * @param {TransactionParams} txParams - params used to build the tx */ verifyTrustlineTxOperations(operations: stellar.Operation[], txParams: TransactionParams): void { - const trustlineOperations = _.filter(operations, ['type', 'changeTrust']) as stellar.Operation.ChangeTrust[]; - if (trustlineOperations.length !== _.get(txParams, 'trustlines', []).length) { - throw new Error('transaction prebuild does not match expected trustline operations'); - } + const trustlineOperations = this.getTrustlineOperationsOrThrow(operations, txParams, 'trustlines'); _.forEach(trustlineOperations, (op: stellar.Operation) => { if (op.type !== 'changeTrust') { throw new Error('Invalid asset type'); @@ -1067,6 +1066,98 @@ export class Xlm extends BaseCoin { }); } + getRecipientOrThrow(txParams: TransactionParams): ITransactionRecipient { + if (!txParams.recipients || txParams.recipients.length === 0) + throw new Error('Missing recipients on token enablement'); + if (txParams.recipients.length > 1) throw new Error('Multiple recipients not supported on token enablement'); + return txParams.recipients[0]; + } + + getTokenDataOrThrow(txParams: TransactionParams): string { + const recipient = this.getRecipientOrThrow(txParams); + const fullTokenData = recipient.tokenName; + if (!fullTokenData || fullTokenData === '') throw new Error('Missing tokenName on token enablement recipient'); + return fullTokenData; + } + + private getTokenCodeFromTokenName(tokenName: string): string { + const tokenCode = tokenName.split(':')[1]?.split('-')[0] ?? ''; + if (tokenCode === '') throw new Error(`Invalid tokenName format on token enablement for token ${tokenName}`); + return tokenCode; + } + + private getIssuerFromTokenName(tokenName: string): string { + const issuer = tokenName.split(':')[1]?.split('-')[1] ?? ''; + if (issuer === '') throw new Error(`Invalid issuer format on token enablement for token ${tokenName}`); + return issuer; + } + + verifyTxType(operations: stellar.Operation[]): void { + operations.forEach((operation) => { + if (!this.isChangeTrustOperation(operation)) + throw new Error( + !operation.type + ? 'Missing operation type on token enablements' + : `Invalid operation on token enablement: expected changeTrust, got ${operation.type}` + ); + }); + } + + verifyAssetType(txParams: TransactionParams, operations: stellar.Operation[]): void { + operations.forEach((operation) => { + const line = this.getTrustlineOperationLineOrThrow(operation); + if (!this.isOperationLineOfAssetType(line)) { + const assetType = line.getAssetType(); + throw new Error(`Invalid asset type on token enablement: got ${assetType}`); + } + }); + } + + verifyTokenIssuer(txParams: TransactionParams, operations: stellar.Operation[]): void { + const fullTokenData = this.getTokenDataOrThrow(txParams); + const expectedIssuer = this.getIssuerFromTokenName(fullTokenData); + + operations.forEach((operation) => { + const line = this.getTrustlineOperationLineOrThrow(operation); + if (!('issuer' in line)) throw new Error('Missing issuer on token enablement operation'); + if (line.issuer !== expectedIssuer) + throw new Error(`Invalid issuer on token enablement operation: expected ${expectedIssuer}, got ${line.issuer}`); + }); + } + + verifyTokenName(txParams: TransactionParams, operations: stellar.Operation[]): void { + const fullTokenData = this.getTokenDataOrThrow(txParams); + const expectedTokenCode = this.getTokenCodeFromTokenName(fullTokenData); + + operations.forEach((operation) => { + const line = this.getTrustlineOperationLineOrThrow(operation); + if (!('code' in line)) throw new Error('Missing token code on token enablement operation'); + if (line.code === '') throw new Error('Empty token code on token enablement operation'); + if (line.code !== expectedTokenCode) + throw new Error( + `Invalid token code on token enablement operation: expected ${expectedTokenCode}, got ${line.code}` + ); + }); + } + + verifyTokenLimits(txParams: TransactionParams, operations: stellar.Operation[]): void { + const recipient = this.getRecipientOrThrow(txParams); + + operations.forEach((operation) => { + // trustline params use limits in base units + const line = this.getTrustlineOperationLineOrThrow(operation); + const limit = this.getTrustlineOperationLimitOrThrow(operation); // line should be stellar.Asset + if (!this.isOperationLineOfAssetType(line)) throw new Error('Invalid asset type'); + const operationLimitBaseUnits = this.bigUnitsToBaseUnits(limit); + const operationToken = this.getTokenNameFromStellarAsset(line); + + // Enable token limit is set to Xlm.maxTrustlineLimit by default + if (recipient.tokenName !== operationToken || operationLimitBaseUnits !== Xlm.maxTrustlineLimit) { + throw new Error('Token limit must be set to max limit on enable token operations'); + } + }); + } + /** * Verify that a transaction prebuild complies with the original intention * @@ -1099,8 +1190,13 @@ export class Xlm extends BaseCoin { (operation) => operation.type === 'createAccount' || operation.type === 'payment' ); - if (txParams.type === 'enabletoken') { - this.verifyEnableTokenTxOperations(tx.operations, txParams); + if (txParams.type === 'enabletoken' && verification.verifyTokenEnablement) { + const trustlineOperations = this.getTrustlineOperationsOrThrow(tx.operations, txParams, 'recipients'); + this.verifyTxType(trustlineOperations); + this.verifyAssetType(txParams, trustlineOperations); + this.verifyTokenIssuer(txParams, trustlineOperations); + this.verifyTokenName(txParams, trustlineOperations); + this.verifyTokenLimits(txParams, trustlineOperations); } else if (txParams.type === 'trustline') { this.verifyTrustlineTxOperations(tx.operations, txParams); } else { diff --git a/modules/sdk-coin-xlm/test/unit/fixtures/blindSigning.ts b/modules/sdk-coin-xlm/test/unit/fixtures/blindSigning.ts new file mode 100644 index 0000000000..b5655b422c --- /dev/null +++ b/modules/sdk-coin-xlm/test/unit/fixtures/blindSigning.ts @@ -0,0 +1,702 @@ +export const tokenEnablements = { + txParams: { + trustlines: [{ token: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', action: 'add' }], + type: 'enabletoken', + recipients: [ + { + tokenName: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + amount: '0', + }, + ], + wallet: { + id: '68c8228d6881d3f00ddd811f2b40cfca', + users: [{ user: '65f9c6797236825d1b4e1f82a1035b46', permissions: ['admin', 'spend', 'view'] }], + coin: 'txlm', + label: 'XLM blind signing', + m: 2, + n: 3, + keys: [ + '68c82276b31dc1f89ab11ba5c5ec97ed', + '68c8227746be62711cb536233c3bd5ce', + '68c8227846be62711cb537bdaa6d6c67', + ], + keySignatures: {}, + enterprise: '66632c6b42b03d265a939048beaaee55', + organization: '66632c6d42b03d265a939107d2f586e5', + bitgoOrg: 'BitGo Trust', + tags: ['68c8228d6881d3f00ddd811f2b40cfca', '66632c6b42b03d265a939048beaaee55'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: false, + coinSpecific: { + rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + pendingChainInitialization: false, + creationFailure: [], + lastMemoId: '0', + trustedTokens: [], + }, + admin: { + policy: { + date: '2025-09-15T14:28:29.703Z', + id: '68c8228d6881d3f00ddd8121ae5575b0', + label: 'default', + rules: [], + version: 0, + latest: true, + }, + }, + clientFlags: [], + walletFlags: [], + allowBackupKeySigning: false, + recoverable: true, + startDate: '2025-09-15T14:28:29.000Z', + type: 'hot', + buildDefaults: {}, + customChangeKeySignatures: {}, + hasLargeNumberOfAddresses: false, + multisigType: 'onchain', + hasReceiveTransferPolicy: false, + creator: '65f9c6797236825d1b4e1f82a1035b46', + walletFullyCreated: true, + config: {}, + balanceString: '99999999600', + confirmedBalanceString: '99999999600', + spendableBalanceString: '99974954600', + reservedBalanceString: '25000000', + receiveAddress: { + id: '68c8228e6881d3f00ddd81379493cb80', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F?memoId=0', + chain: 0, + index: 0, + coin: 'txlm', + wallet: '68c8228d6881d3f00ddd811f2b40cfca', + coinSpecific: { rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', memoId: '0' }, + }, + pendingApprovals: [], + }, + enableTokens: [{ name: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }], + walletId: '68c8228d6881d3f00ddd811f2b40cfca', + coin: 'txlm', + walletPassphrase: 'F5R*KDzwg3Wjg3s', + prebuildTx: { + txHex: + '0000000200000000c9b5ca4f4c49db626480ad78f3404fd318b946c2e74f2da3d32cd408e14bbf2e0000afc8000865760000000200000001000000000000000000000000000000000000000000000001000000000000000600000001425354000000000061343a5bb98e1faeb830550aa4c2cee3c4afa4e0832b27a05a13438552f6bdbf7fffffffffffffff0000000000000000', + txBase64: + 'AAAAAgAAAADJtcpPTEnbYmSArXjzQE/TGLlGwudPLaPTLNQI4Uu/LgAAr8gACGV2AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABgAAAAFCU1QAAAAAAGE0Olu5jh+uuDBVCqTCzuPEr6TggysnoFoTQ4VS9r2/f/////////8AAAAAAAAAAA==', + txInfo: { + _networkPassphrase: 'Test SDF Network ; September 2015', + _tx: { + _attributes: { + fee: 45000, + seqNum: { low: 2, high: 550262, unsigned: false }, + memo: { _switch: { name: 'memoNone', value: 0 }, _arm: {}, _armType: {} }, + timeBounds: { + _attributes: { + minTime: { low: 0, high: 0, unsigned: true }, + maxTime: { low: 0, high: 0, unsigned: true }, + }, + }, + sourceAccount: { + _switch: { name: 'keyTypeEd25519', value: 0 }, + _arm: 'ed25519', + _armType: { _length: 32, _padding: 0 }, + _value: { + type: 'Buffer', + data: [ + 201, 181, 202, 79, 76, 73, 219, 98, 100, 128, 173, 120, 243, 64, 79, 211, 24, 185, 70, 194, 231, 79, + 45, 163, 211, 44, 212, 8, 225, 75, 191, 46, + ], + }, + }, + ext: { _switch: 0, _arm: {}, _armType: {} }, + operations: [ + { + _attributes: { + body: { + _switch: { name: 'changeTrust', value: 6 }, + _arm: 'changeTrustOp', + _value: { + _attributes: { + line: { + _switch: { name: 'assetTypeCreditAlphanum4', value: 1 }, + _arm: 'alphaNum4', + _value: { + _attributes: { + assetCode: 'BST', + issuer: { + _switch: { name: 'publicKeyTypeEd25519', value: 0 }, + _arm: 'ed25519', + _armType: { _length: 32, _padding: 0 }, + _value: { + type: 'Buffer', + data: [ + 97, 52, 58, 91, 185, 142, 31, 174, 184, 48, 85, 10, 164, 194, 206, 227, 196, 175, + 164, 224, 131, 43, 39, 160, 90, 19, 67, 133, 82, 246, 189, 191, + ], + }, + }, + }, + }, + }, + limit: { low: -1, high: 2147483647, unsigned: false }, + }, + }, + }, + }, + }, + ], + }, + }, + _signatures: [], + _fee: '45000', + _envelopeType: { name: 'envelopeTypeTx', value: 2 }, + _sequence: '2363357294231554', + _source: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + _timeBounds: { minTime: '0', maxTime: '0' }, + _operations: [ + { + type: 'changeTrust', + line: { code: 'BST', issuer: 'GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }, + limit: '922337203685.4775807', + }, + ], + fee: '45000', + operations: [ + { + type: 'changeTrust', + line: { code: 'BST', issuer: 'GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }, + limit: '922337203685.4775807', + }, + ], + }, + feeInfo: { + date: '2025-09-16T20:53:21.483Z', + height: 572115, + baseReserve: '5000000', + baseFee: '100', + fee: 45000, + feeString: '45000', + }, + coin: 'txlm', + walletId: '68c8228d6881d3f00ddd811f2b40cfca', + buildParams: { + trustlines: [{ token: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', action: 'add' }], + type: 'enabletoken', + recipients: [ + { + tokenName: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + amount: '0', + }, + ], + wallet: { + id: '68c8228d6881d3f00ddd811f2b40cfca', + users: [{ user: '65f9c6797236825d1b4e1f82a1035b46', permissions: ['admin', 'spend', 'view'] }], + coin: 'txlm', + label: 'XLM blind signing', + m: 2, + n: 3, + keys: [ + '68c82276b31dc1f89ab11ba5c5ec97ed', + '68c8227746be62711cb536233c3bd5ce', + '68c8227846be62711cb537bdaa6d6c67', + ], + keySignatures: {}, + enterprise: '66632c6b42b03d265a939048beaaee55', + organization: '66632c6d42b03d265a939107d2f586e5', + bitgoOrg: 'BitGo Trust', + tags: ['68c8228d6881d3f00ddd811f2b40cfca', '66632c6b42b03d265a939048beaaee55'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: false, + coinSpecific: { + rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + pendingChainInitialization: false, + creationFailure: [], + lastMemoId: '0', + trustedTokens: [], + }, + admin: { + policy: { + date: '2025-09-15T14:28:29.703Z', + id: '68c8228d6881d3f00ddd8121ae5575b0', + label: 'default', + rules: [], + version: 0, + latest: true, + }, + }, + clientFlags: [], + walletFlags: [], + allowBackupKeySigning: false, + recoverable: true, + startDate: '2025-09-15T14:28:29.000Z', + type: 'hot', + buildDefaults: {}, + customChangeKeySignatures: {}, + hasLargeNumberOfAddresses: false, + multisigType: 'onchain', + hasReceiveTransferPolicy: false, + creator: '65f9c6797236825d1b4e1f82a1035b46', + walletFullyCreated: true, + config: {}, + balanceString: '99999999600', + confirmedBalanceString: '99999999600', + spendableBalanceString: '99974954600', + reservedBalanceString: '25000000', + receiveAddress: { + id: '68c8228e6881d3f00ddd81379493cb80', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F?memoId=0', + chain: 0, + index: 0, + coin: 'txlm', + wallet: '68c8228d6881d3f00ddd811f2b40cfca', + coinSpecific: { rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', memoId: '0' }, + }, + pendingApprovals: [], + }, + }, + }, + }, + txPrebuild: { + txHex: + '0000000200000000c9b5ca4f4c49db626480ad78f3404fd318b946c2e74f2da3d32cd408e14bbf2e0000afc8000865760000000200000001000000000000000000000000000000000000000000000001000000000000000600000001425354000000000061343a5bb98e1faeb830550aa4c2cee3c4afa4e0832b27a05a13438552f6bdbf7fffffffffffffff0000000000000000', + txBase64: + 'AAAAAgAAAADJtcpPTEnbYmSArXjzQE/TGLlGwudPLaPTLNQI4Uu/LgAAr8gACGV2AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABgAAAAFCU1QAAAAAAGE0Olu5jh+uuDBVCqTCzuPEr6TggysnoFoTQ4VS9r2/f/////////8AAAAAAAAAAA==', + txInfo: { + _networkPassphrase: 'Test SDF Network ; September 2015', + _tx: { + _attributes: { + fee: 45000, + seqNum: { low: 2, high: 550262, unsigned: false }, + memo: { _switch: { name: 'memoNone', value: 0 }, _arm: {}, _armType: {} }, + timeBounds: { + _attributes: { minTime: { low: 0, high: 0, unsigned: true }, maxTime: { low: 0, high: 0, unsigned: true } }, + }, + sourceAccount: { + _switch: { name: 'keyTypeEd25519', value: 0 }, + _arm: 'ed25519', + _armType: { _length: 32, _padding: 0 }, + _value: { + type: 'Buffer', + data: [ + 201, 181, 202, 79, 76, 73, 219, 98, 100, 128, 173, 120, 243, 64, 79, 211, 24, 185, 70, 194, 231, 79, 45, + 163, 211, 44, 212, 8, 225, 75, 191, 46, + ], + }, + }, + ext: { _switch: 0, _arm: {}, _armType: {} }, + operations: [ + { + _attributes: { + body: { + _switch: { name: 'changeTrust', value: 6 }, + _arm: 'changeTrustOp', + _value: { + _attributes: { + line: { + _switch: { name: 'assetTypeCreditAlphanum4', value: 1 }, + _arm: 'alphaNum4', + _value: { + _attributes: { + assetCode: 'BST', + issuer: { + _switch: { name: 'publicKeyTypeEd25519', value: 0 }, + _arm: 'ed25519', + _armType: { _length: 32, _padding: 0 }, + _value: { + type: 'Buffer', + data: [ + 97, 52, 58, 91, 185, 142, 31, 174, 184, 48, 85, 10, 164, 194, 206, 227, 196, 175, 164, + 224, 131, 43, 39, 160, 90, 19, 67, 133, 82, 246, 189, 191, + ], + }, + }, + }, + }, + }, + limit: { low: -1, high: 2147483647, unsigned: false }, + }, + }, + }, + }, + }, + ], + }, + }, + _signatures: [], + _fee: '45000', + _envelopeType: { name: 'envelopeTypeTx', value: 2 }, + _sequence: '2363357294231554', + _source: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + _timeBounds: { minTime: '0', maxTime: '0' }, + _operations: [ + { + type: 'changeTrust', + line: { code: 'BST', issuer: 'GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }, + limit: '922337203685.4775807', + }, + ], + fee: '45000', + operations: [ + { + type: 'changeTrust', + line: { code: 'BST', issuer: 'GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }, + limit: '922337203685.4775807', + }, + ], + }, + feeInfo: { + date: '2025-09-16T20:53:21.483Z', + height: 572115, + baseReserve: '5000000', + baseFee: '100', + fee: 45000, + feeString: '45000', + }, + coin: 'txlm', + walletId: '68c8228d6881d3f00ddd811f2b40cfca', + buildParams: { + trustlines: [{ token: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', action: 'add' }], + type: 'enabletoken', + recipients: [ + { + tokenName: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + amount: '0', + }, + ], + wallet: { + id: '68c8228d6881d3f00ddd811f2b40cfca', + users: [{ user: '65f9c6797236825d1b4e1f82a1035b46', permissions: ['admin', 'spend', 'view'] }], + coin: 'txlm', + label: 'XLM blind signing', + m: 2, + n: 3, + keys: [ + '68c82276b31dc1f89ab11ba5c5ec97ed', + '68c8227746be62711cb536233c3bd5ce', + '68c8227846be62711cb537bdaa6d6c67', + ], + keySignatures: {}, + enterprise: '66632c6b42b03d265a939048beaaee55', + organization: '66632c6d42b03d265a939107d2f586e5', + bitgoOrg: 'BitGo Trust', + tags: ['68c8228d6881d3f00ddd811f2b40cfca', '66632c6b42b03d265a939048beaaee55'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: false, + coinSpecific: { + rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + pendingChainInitialization: false, + creationFailure: [], + lastMemoId: '0', + trustedTokens: [], + }, + admin: { + policy: { + date: '2025-09-15T14:28:29.703Z', + id: '68c8228d6881d3f00ddd8121ae5575b0', + label: 'default', + rules: [], + version: 0, + latest: true, + }, + }, + clientFlags: [], + walletFlags: [], + allowBackupKeySigning: false, + recoverable: true, + startDate: '2025-09-15T14:28:29.000Z', + type: 'hot', + buildDefaults: {}, + customChangeKeySignatures: {}, + hasLargeNumberOfAddresses: false, + multisigType: 'onchain', + hasReceiveTransferPolicy: false, + creator: '65f9c6797236825d1b4e1f82a1035b46', + walletFullyCreated: true, + config: {}, + balanceString: '99999999600', + confirmedBalanceString: '99999999600', + spendableBalanceString: '99974954600', + reservedBalanceString: '25000000', + receiveAddress: { + id: '68c8228e6881d3f00ddd81379493cb80', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F?memoId=0', + chain: 0, + index: 0, + coin: 'txlm', + wallet: '68c8228d6881d3f00ddd811f2b40cfca', + coinSpecific: { rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', memoId: '0' }, + }, + pendingApprovals: [], + }, + }, + }, + walletData: { + id: '68c8228d6881d3f00ddd811f2b40cfca', + users: [{ user: '65f9c6797236825d1b4e1f82a1035b46', permissions: ['admin', 'spend', 'view'] }], + coin: 'txlm', + label: 'XLM blind signing', + m: 2, + n: 3, + keys: ['68c82276b31dc1f89ab11ba5c5ec97ed', '68c8227746be62711cb536233c3bd5ce', '68c8227846be62711cb537bdaa6d6c67'], + keySignatures: {}, + enterprise: '66632c6b42b03d265a939048beaaee55', + organization: '66632c6d42b03d265a939107d2f586e5', + bitgoOrg: 'BitGo Trust', + tags: ['68c8228d6881d3f00ddd811f2b40cfca', '66632c6b42b03d265a939048beaaee55'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: false, + coinSpecific: { + rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + pendingChainInitialization: false, + creationFailure: [], + lastMemoId: '0', + trustedTokens: [], + }, + admin: { + policy: { + date: '2025-09-15T14:28:29.703Z', + id: '68c8228d6881d3f00ddd8121ae5575b0', + label: 'default', + rules: [], + version: 0, + latest: true, + }, + }, + clientFlags: [], + walletFlags: [], + allowBackupKeySigning: false, + recoverable: true, + startDate: '2025-09-15T14:28:29.000Z', + type: 'hot', + buildDefaults: {}, + customChangeKeySignatures: {}, + hasLargeNumberOfAddresses: false, + multisigType: 'onchain', + hasReceiveTransferPolicy: false, + creator: '65f9c6797236825d1b4e1f82a1035b46', + walletFullyCreated: true, + config: {}, + balanceString: '99999999600', + confirmedBalanceString: '99999999600', + spendableBalanceString: '99974954600', + reservedBalanceString: '25000000', + receiveAddress: { + id: '68c8228e6881d3f00ddd81379493cb80', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F?memoId=0', + chain: 0, + index: 0, + coin: 'txlm', + wallet: '68c8228d6881d3f00ddd811f2b40cfca', + coinSpecific: { rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', memoId: '0' }, + }, + pendingApprovals: [], + }, + sendTokenEnablementPayload: { + enableTokens: [{ name: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }], + walletId: '68c8228d6881d3f00ddd811f2b40cfca', + coin: 'txlm', + type: 'enabletoken', + trustlines: [{ token: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', action: 'add' }], + walletPassphrase: 'F5R*KDzwg3Wjg3s', + prebuildTx: { + txHex: + '0000000200000000c9b5ca4f4c49db626480ad78f3404fd318b946c2e74f2da3d32cd408e14bbf2e0000afc8000865760000000200000001000000000000000000000000000000000000000000000001000000000000000600000001425354000000000061343a5bb98e1faeb830550aa4c2cee3c4afa4e0832b27a05a13438552f6bdbf7fffffffffffffff0000000000000000', + txBase64: + 'AAAAAgAAAADJtcpPTEnbYmSArXjzQE/TGLlGwudPLaPTLNQI4Uu/LgAAr8gACGV2AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABgAAAAFCU1QAAAAAAGE0Olu5jh+uuDBVCqTCzuPEr6TggysnoFoTQ4VS9r2/f/////////8AAAAAAAAAAA==', + txInfo: { + _networkPassphrase: 'Test SDF Network ; September 2015', + _tx: { + _attributes: { + fee: 45000, + seqNum: { low: 2, high: 550262, unsigned: false }, + memo: { _switch: { name: 'memoNone', value: 0 }, _arm: {}, _armType: {} }, + timeBounds: { + _attributes: { + minTime: { low: 0, high: 0, unsigned: true }, + maxTime: { low: 0, high: 0, unsigned: true }, + }, + }, + sourceAccount: { + _switch: { name: 'keyTypeEd25519', value: 0 }, + _arm: 'ed25519', + _armType: { _length: 32, _padding: 0 }, + _value: { + type: 'Buffer', + data: [ + 201, 181, 202, 79, 76, 73, 219, 98, 100, 128, 173, 120, 243, 64, 79, 211, 24, 185, 70, 194, 231, 79, + 45, 163, 211, 44, 212, 8, 225, 75, 191, 46, + ], + }, + }, + ext: { _switch: 0, _arm: {}, _armType: {} }, + operations: [ + { + _attributes: { + body: { + _switch: { name: 'changeTrust', value: 6 }, + _arm: 'changeTrustOp', + _value: { + _attributes: { + line: { + _switch: { name: 'assetTypeCreditAlphanum4', value: 1 }, + _arm: 'alphaNum4', + _value: { + _attributes: { + assetCode: 'BST', + issuer: { + _switch: { name: 'publicKeyTypeEd25519', value: 0 }, + _arm: 'ed25519', + _armType: { _length: 32, _padding: 0 }, + _value: { + type: 'Buffer', + data: [ + 97, 52, 58, 91, 185, 142, 31, 174, 184, 48, 85, 10, 164, 194, 206, 227, 196, 175, + 164, 224, 131, 43, 39, 160, 90, 19, 67, 133, 82, 246, 189, 191, + ], + }, + }, + }, + }, + }, + limit: { low: -1, high: 2147483647, unsigned: false }, + }, + }, + }, + }, + }, + ], + }, + }, + _signatures: [], + _fee: '45000', + _envelopeType: { name: 'envelopeTypeTx', value: 2 }, + _sequence: '2363357294231554', + _source: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + _timeBounds: { minTime: '0', maxTime: '0' }, + _operations: [ + { + type: 'changeTrust', + line: { code: 'BST', issuer: 'GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }, + limit: '922337203685.4775807', + }, + ], + fee: '45000', + operations: [ + { + type: 'changeTrust', + line: { code: 'BST', issuer: 'GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L' }, + limit: '922337203685.4775807', + }, + ], + }, + feeInfo: { + date: '2025-09-29T15:59:56.187Z', + height: 792995, + baseReserve: '5000000', + baseFee: '100', + fee: 45000, + feeString: '45000', + }, + coin: 'txlm', + walletId: '68c8228d6881d3f00ddd811f2b40cfca', + buildParams: { + trustlines: [{ token: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', action: 'add' }], + type: 'enabletoken', + recipients: [ + { + tokenName: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + amount: '0', + }, + ], + wallet: { + id: '68c8228d6881d3f00ddd811f2b40cfca', + users: [{ user: '65f9c6797236825d1b4e1f82a1035b46', permissions: ['admin', 'spend', 'view'] }], + coin: 'txlm', + label: 'XLM blind signing', + m: 2, + n: 3, + keys: [ + '68c82276b31dc1f89ab11ba5c5ec97ed', + '68c8227746be62711cb536233c3bd5ce', + '68c8227846be62711cb537bdaa6d6c67', + ], + keySignatures: {}, + enterprise: '66632c6b42b03d265a939048beaaee55', + organization: '66632c6d42b03d265a939107d2f586e5', + bitgoOrg: 'BitGo Trust', + tags: ['68c8228d6881d3f00ddd811f2b40cfca', '66632c6b42b03d265a939048beaaee55'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: false, + coinSpecific: { + rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', + pendingChainInitialization: false, + creationFailure: [], + lastMemoId: '0', + trustedTokens: [], + }, + admin: { + policy: { + date: '2025-09-15T14:28:29.703Z', + id: '68c8228d6881d3f00ddd8121ae5575b0', + label: 'default', + rules: [], + version: 0, + latest: true, + }, + }, + clientFlags: [], + walletFlags: [], + allowBackupKeySigning: false, + recoverable: true, + startDate: '2025-09-15T14:28:29.000Z', + type: 'hot', + buildDefaults: {}, + customChangeKeySignatures: {}, + hasLargeNumberOfAddresses: false, + multisigType: 'onchain', + hasReceiveTransferPolicy: false, + creator: '65f9c6797236825d1b4e1f82a1035b46', + walletFullyCreated: true, + config: {}, + balanceString: '99999999600', + confirmedBalanceString: '99999999600', + spendableBalanceString: '99974954600', + reservedBalanceString: '25000000', + receiveAddress: { + id: '68c8228e6881d3f00ddd81379493cb80', + address: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F?memoId=0', + chain: 0, + index: 0, + coin: 'txlm', + wallet: '68c8228d6881d3f00ddd811f2b40cfca', + coinSpecific: { rootAddress: 'GDE3LSSPJRE5WYTEQCWXR42AJ7JRROKGYLTU6LND2MWNICHBJO7S5P5F', memoId: '0' }, + }, + pendingApprovals: [], + }, + }, + }, + }, + + wrongTxTypeTxPrebuildBase64: + 'AAAAAgAAAADJtcpPTEnbYmSArXjzQE/TGLlGwudPLaPTLNQI4Uu/LgAAr8gACGV2AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADJtcpPTEnbYmSArXjzQE/TGLlGwudPLaPTLNQI4Uu/LgAAAAAAAAAAAA9CQAAAAAAAAAAA', + wrongTokenTxPrebuildBase64: + 'AAAAAgAAAADJtcpPTEnbYmSArXjzQE/TGLlGwudPLaPTLNQI4Uu/LgAAr8gACGV2AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABgAAAAFUU1QAAAAAAGE0Olu5jh+uuDBVCqTCzuPEr6TggysnoFoTQ4VS9r2/f/////////8AAAAAAAAAAA==', +}; diff --git a/modules/sdk-coin-xlm/test/unit/xlm.ts b/modules/sdk-coin-xlm/test/unit/xlm.ts index dfa6bc9975..8691241353 100644 --- a/modules/sdk-coin-xlm/test/unit/xlm.ts +++ b/modules/sdk-coin-xlm/test/unit/xlm.ts @@ -1,15 +1,16 @@ -import * as should from 'should'; import { randomBytes } from 'crypto'; +import * as should from 'should'; import * as stellar from 'stellar-sdk'; -import { Environments, Wallet } from '@bitgo/sdk-core'; -import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; +import { Environments, PrebuildAndSignTransactionOptions, Wallet } from '@bitgo/sdk-core'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { Txlm } from '../../src'; import { KeyPair } from '../../src/lib/keyPair'; -import nock from 'nock'; import * as assert from 'assert'; +import nock from 'nock'; +import { tokenEnablements as tokenEnablementsBlindSigningMocks } from './fixtures/blindSigning'; import { xlmBackupKey } from './fixtures/xlmBackupKey'; nock.enableNetConnect(); @@ -870,6 +871,7 @@ describe('XLM:', function () { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub }, }, + verifyTokenEnablement: true, }; const validTransaction = await basecoin.verifyTransaction({ txParams, txPrebuild, wallet, verification }); validTransaction.should.equal(true); @@ -877,7 +879,7 @@ describe('XLM:', function () { }); describe('enabletoken transactions', function () { - it('should fail to verify a enbletoken transaction with unmatching number of token', async function () { + it('should fail to verify a token enablement tx with unmatching number of tokens', async function () { const txParams = { recipients: [], type: 'enabletoken', @@ -897,18 +899,20 @@ describe('XLM:', function () { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub }, }, + + verifyTokenEnablement: true, }; await basecoin .verifyTransaction({ txParams, txPrebuild, wallet, verification }) .should.be.rejectedWith('transaction prebuild does not match expected trustline operations'); }); - it('should fail to verify a enbletoken transaction with unmatching token', async function () { + it('should fail to verify a token enablement tx with unmatching token', async function () { const txParams = { type: 'enabletoken', recipients: [ { - token: 'txlm:BST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', + tokenName: 'txlm:TST-GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L', amount: 0, address: '', }, @@ -929,11 +933,83 @@ describe('XLM:', function () { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub }, }, + verifyTokenEnablement: true, }; await basecoin .verifyTransaction({ txParams, txPrebuild, wallet, verification }) - .should.be.rejectedWith('transaction prebuild does not match expected trustline tokens'); + .should.be.rejectedWith('Invalid token code on token enablement operation: expected TST, got BST'); + }); + }); + + it('should verify a token enablement when user data matchs prebuild data', async function () { + const { txParams, txPrebuild, walletData } = tokenEnablementsBlindSigningMocks; + const wallet = new Wallet(bitgo, basecoin, walletData); + const sameIntentTx = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { verifyTokenEnablement: true }, }); + sameIntentTx.should.equal(true); + }); + + it('should fail a token enablement when tx type is not a token enablement on txHex', async function () { + const { txParams, txPrebuild, walletData, wrongTxTypeTxPrebuildBase64 } = tokenEnablementsBlindSigningMocks; + + const wallet = new Wallet(bitgo, basecoin, walletData); + // We send a "payment" in txBase64 instead of a "changeTrust" + const withdrawalTypeTxPrebuild = { + ...txPrebuild, + txBase64: wrongTxTypeTxPrebuildBase64, + }; + await assert.rejects( + async () => + await basecoin.verifyTransaction({ + txParams, + txPrebuild: withdrawalTypeTxPrebuild, + wallet, + verification: { verifyTokenEnablement: true }, + }), + { + message: 'transaction prebuild does not match expected trustline operations', + } + ); + }); + + it('should fail a token enablement when token is not the same on txHex', async function () { + const { txParams, txPrebuild, walletData, wrongTokenTxPrebuildBase64 } = tokenEnablementsBlindSigningMocks; + const wallet = new Wallet(bitgo, basecoin, walletData); + const differentTokenTxPrebuild = { ...txPrebuild, txBase64: wrongTokenTxPrebuildBase64 }; + await assert.rejects( + async () => + await basecoin.verifyTransaction({ + txParams, + txPrebuild: differentTokenTxPrebuild, + wallet, + + verification: { verifyTokenEnablement: true }, + }), + { + message: 'Invalid token code on token enablement operation: expected BST, got TST', + } + ); + }); + + it('should throw an error on spoofed send token enablement call', async function () { + const { sendTokenEnablementPayload, walletData, wrongTokenTxPrebuildBase64 } = tokenEnablementsBlindSigningMocks; + const wallet = new Wallet(bitgo, basecoin, walletData); + + nock(uri).get('/api/v2/txlm/key/68c82276b31dc1f89ab11ba5c5ec97ed').reply(200, {}); + await assert.rejects( + async () => + wallet.sendTokenEnablement({ + ...sendTokenEnablementPayload, + prebuildTx: { ...sendTokenEnablementPayload.prebuildTx, txBase64: wrongTokenTxPrebuildBase64 }, + } as unknown as PrebuildAndSignTransactionOptions), + { + message: 'Invalid token code on token enablement operation: expected BST, got TST', + } + ); }); });