From 416b1f5e7c820684e7d0e8008869ab87f0922777 Mon Sep 17 00:00:00 2001 From: Prajwal U Date: Thu, 29 Oct 2026 15:28:19 +0530 Subject: [PATCH] feat(sdk-coin-xlm): report full setOptions fields and unmodelled ops in explain Problem/goal: explainTransaction for XLM reported only setFlags, clearFlags and lowThreshold for a setOptions operation and silently omitted every other operation type it does not model. A reviewer reading the explanation, for example in offline vault console, could therefore sign a transaction that adds a signer, sets thresholds, a master weight, a home domain or an inflation destination, that runs an operation against another account, or that carries an extra operation, without seeing it. authorizeTrustline entries also reported only the authorized flag, so a change through authorizedToMaintainLiabilities or clawbackEnabled was invisible, and an undefined authorized was not distinguishable from a revoke. Implementation details: - accountConfig operations now also carry masterWeight, medThreshold, highThreshold, homeDomain, inflationDest and signer. Each is undefined when the operation does not set it, and a masterWeight or signer weight of 0 is kept (weight 0 means signer removal). A signer is reported as { type, key, weight } for ed25519 public key, sha256 hash, pre-auth transaction and signed payload signers. - authorizeTrustline operations now carry authorized, authorizedToMaintainLiabilities and clawbackEnabled. Each is true when the operation sets the flag, false when it clears it and undefined when it does not touch it. - clawback operations now carry assetCode and assetIssuer so consumers do not parse the coin name to label the amount. - Every operations entry and every output now carries source, set only when the operation names its own source account, so an operation that runs against another account is visible. - An operation type that explain does not model now yields an entry of type 'unsupported' with the stellar operationType, instead of being dropped. Payment and createAccount remain reported through outputs and changeTrust through operations. - All additions are optional fields, so existing consumers keep working; consumers that assume every operations entry is one of the known types will now also see 'unsupported' entries. Verification: Added unit tests in modules/sdk-coin-xlm/test/unit/xlm.ts covering each new setOptions field, unset fields, signer variants and removal, the three trustline flags, the clawback asset fields, operation source on every operation kind and its absence, the unsupported entry (including that payment and changeTrust do not produce one) and operation order for a mixed transaction. The explain tests pass. The full module suite shows the same 7 failures with and without the change, caused by a stale locally built sdk-core, and tsc reports the same errors as before. Prettier and eslint are clean. Ticket: CSHLD-2079 --- modules/sdk-coin-xlm/src/xlm.ts | 64 ++++++- modules/sdk-coin-xlm/test/unit/xlm.ts | 235 ++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) diff --git a/modules/sdk-coin-xlm/src/xlm.ts b/modules/sdk-coin-xlm/src/xlm.ts index 9a92404a2f..ee7d231bdc 100644 --- a/modules/sdk-coin-xlm/src/xlm.ts +++ b/modules/sdk-coin-xlm/src/xlm.ts @@ -127,25 +127,70 @@ interface TransactionMemo { type?: string; } +/** + * A signer added, re-weighted or (weight 0) removed by a setOptions operation. + * ed25519PublicKey and ed25519SignedPayload keys are strkeys; sha256Hash and preAuthTx keys are hex. + */ +interface TransactionSigner { + type: 'ed25519PublicKey' | 'sha256Hash' | 'preAuthTx' | 'ed25519SignedPayload'; + key: string; + weight?: number; +} + interface TransactionOperation { + // 'unsupported' marks an operation explain does not model, so it is never silently absent from the result type: string; coin: string; + // set only when the operation names its own source account, i.e. it runs against an account other than the tx source + source?: string; limit?: string; asset?: stellar.Asset; - // accountConfig (setOptions) operation fields + // unsupported operation field: the stellar operation type that was not modelled + operationType?: string; + // accountConfig (setOptions) operation fields; each is undefined when the operation does not set it setFlags?: number; clearFlags?: number; + masterWeight?: number; lowThreshold?: number; + medThreshold?: number; + highThreshold?: number; + homeDomain?: string; + inflationDest?: string; + signer?: TransactionSigner; // authorizeTrustline (setTrustLineFlags) operation fields trustor?: string; + // Each trustline flag is true when the operation sets it, false when it clears it, and undefined when the + // operation does not touch it. undefined is therefore never a revoke, and an operation can deauthorize a + // trustline through authorizedToMaintainLiabilities or authorized, so consumers must read all three flags. authorized?: boolean; + authorizedToMaintainLiabilities?: boolean; + clawbackEnabled?: boolean; // clawback operation fields from?: string; amount?: string; + // clawback asset, so consumers need not parse the coin name to label the amount + assetCode?: string; + assetIssuer?: string; +} + +function describeSigner(signer: stellar.Signer): TransactionSigner { + const weight = signer.weight === undefined ? undefined : Number(signer.weight); + if ('ed25519PublicKey' in signer) { + return { type: 'ed25519PublicKey', key: signer.ed25519PublicKey, weight }; + } + if ('sha256Hash' in signer) { + return { type: 'sha256Hash', key: signer.sha256Hash.toString('hex'), weight }; + } + if ('preAuthTx' in signer) { + return { type: 'preAuthTx', key: signer.preAuthTx.toString('hex'), weight }; + } + return { type: 'ed25519SignedPayload', key: signer.ed25519SignedPayload, weight }; } interface TransactionOutput extends BaseTransactionOutput { coin: string; + // set only when the operation names its own source account, i.e. it runs against an account other than the tx source + source?: string; } interface TransactionExplanation extends BaseTransactionExplanation { @@ -1052,6 +1097,7 @@ export class Xlm extends BaseCoin { ), address: op.destination + memoId, coin, + source: op.source, }; if (!_.isUndefined(spendAmounts[coin])) { @@ -1072,6 +1118,7 @@ export class Xlm extends BaseCoin { operations.push({ type: op.type, coin: this.getTokenNameFromStellarAsset(asset), + source: op.source, asset, limit: this.bigUnitsToBaseUnits(op.limit), }); @@ -1080,26 +1127,41 @@ export class Xlm extends BaseCoin { operations.push({ type: 'accountConfig', coin: this.getChain(), + source: op.source, setFlags: setOptionsOp.setFlags, clearFlags: setOptionsOp.clearFlags, + masterWeight: setOptionsOp.masterWeight, lowThreshold: setOptionsOp.lowThreshold, + medThreshold: setOptionsOp.medThreshold, + highThreshold: setOptionsOp.highThreshold, + homeDomain: setOptionsOp.homeDomain, + inflationDest: setOptionsOp.inflationDest, + signer: setOptionsOp.signer ? describeSigner(setOptionsOp.signer) : undefined, }); } else if (op.type === 'setTrustLineFlags') { const setTrustLineFlagsOp = op as stellar.Operation.SetTrustLineFlags; operations.push({ type: 'authorizeTrustline', coin: this.getTokenNameFromStellarAsset(setTrustLineFlagsOp.asset), + source: op.source, trustor: setTrustLineFlagsOp.trustor, authorized: setTrustLineFlagsOp.flags?.authorized, + authorizedToMaintainLiabilities: setTrustLineFlagsOp.flags?.authorizedToMaintainLiabilities, + clawbackEnabled: setTrustLineFlagsOp.flags?.clawbackEnabled, }); } else if (op.type === 'clawback') { const clawbackOp = op as stellar.Operation.Clawback; operations.push({ type: 'clawback', coin: this.getTokenNameFromStellarAsset(clawbackOp.asset), + source: op.source, from: clawbackOp.from, amount: this.bigUnitsToBaseUnits(clawbackOp.amount), + assetCode: clawbackOp.asset.code, + assetIssuer: clawbackOp.asset.issuer, }); + } else { + operations.push({ type: 'unsupported', coin: this.getChain(), operationType: op.type, source: op.source }); } }); diff --git a/modules/sdk-coin-xlm/test/unit/xlm.ts b/modules/sdk-coin-xlm/test/unit/xlm.ts index e817112804..38874d3f45 100644 --- a/modules/sdk-coin-xlm/test/unit/xlm.ts +++ b/modules/sdk-coin-xlm/test/unit/xlm.ts @@ -1981,5 +1981,240 @@ describe('XLM:', function () { // 10 big units clawed back => 10 * 1e7 = 100000000 base units in the explanation. clawbackOperation.amount.should.equal('100000000'); }); + + it('should report the asset code and issuer of a clawback so the amount can be labelled without parsing the coin name', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.clawback({ from: trustorAddress, asset: bstAsset, amount: '1' }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + const clawbackOperation = explanation.operations[0]; + clawbackOperation.assetCode.should.equal('BST'); + clawbackOperation.assetIssuer.should.equal(issuerAddress); + }); + + it('should leave authorized undefined when a setTrustLineFlags op does not touch the authorized flag', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setTrustLineFlags({ + trustor: trustorAddress, + asset: bstAsset, + flags: { authorizedToMaintainLiabilities: true }, + }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + const authorizeTrustlineOperation = explanation.operations[0]; + authorizeTrustlineOperation.type.should.equal('authorizeTrustline'); + // Consumers must be able to tell "not changed" (undefined) apart from a revoke (false). + should.not.exist(authorizeTrustlineOperation.authorized); + }); + + it('should report authorized false for a revoke', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setTrustLineFlags({ + trustor: trustorAddress, + asset: bstAsset, + flags: { authorized: false }, + }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + explanation.operations[0].authorized.should.equal(false); + }); + + it('should report all three trustline flags so a deauthorization through any of them is visible', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setTrustLineFlags({ + trustor: trustorAddress, + asset: bstAsset, + flags: { authorized: false, authorizedToMaintainLiabilities: true, clawbackEnabled: false }, + }), + stellar.Operation.setTrustLineFlags({ + trustor: trustorAddress, + asset: bstAsset, + flags: { authorizedToMaintainLiabilities: false }, + }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + // A downgrade to maintain-liabilities is not a full revoke, and clearing maintain-liabilities can + // deauthorize a trustline while `authorized` stays undefined. + const [downgrade, clearMaintainLiabilities] = explanation.operations; + downgrade.authorized.should.equal(false); + downgrade.authorizedToMaintainLiabilities.should.equal(true); + downgrade.clawbackEnabled.should.equal(false); + should.not.exist(clearMaintainLiabilities.authorized); + clearMaintainLiabilities.authorizedToMaintainLiabilities.should.equal(false); + should.not.exist(clearMaintainLiabilities.clawbackEnabled); + }); + + describe('setOptions fields beyond flags and lowThreshold', () => { + const inflationDestination = 'GAFVCOWZWSJEAFOKBEBO2B4QITJ2YXN6YIYG6BUURQINVDVW4OPS3OL6'; + + it('should report every setOptions field the transaction sets', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setOptions({ + masterWeight: 0, + lowThreshold: 2, + medThreshold: 3, + highThreshold: 4, + homeDomain: 'example.com', + inflationDest: inflationDestination, + }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + const accountConfigOperation = explanation.operations[0]; + accountConfigOperation.type.should.equal('accountConfig'); + // masterWeight 0 is a real value (it disables the master key) and must not be dropped as falsy. + accountConfigOperation.masterWeight.should.equal(0); + accountConfigOperation.lowThreshold.should.equal(2); + accountConfigOperation.medThreshold.should.equal(3); + accountConfigOperation.highThreshold.should.equal(4); + accountConfigOperation.homeDomain.should.equal('example.com'); + accountConfigOperation.inflationDest.should.equal(inflationDestination); + }); + + it('should leave unset setOptions fields undefined', async () => { + const txBase64 = buildTxBase64([stellar.Operation.setOptions({ setFlags: stellar.AuthRequiredFlag })]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + const accountConfigOperation = explanation.operations[0]; + accountConfigOperation.setFlags.should.equal(stellar.AuthRequiredFlag); + should.not.exist(accountConfigOperation.masterWeight); + should.not.exist(accountConfigOperation.medThreshold); + should.not.exist(accountConfigOperation.highThreshold); + should.not.exist(accountConfigOperation.homeDomain); + should.not.exist(accountConfigOperation.inflationDest); + }); + + describe('signer', () => { + const signerAddress = 'GBTL47RTFR5EKMZSXWOQU735WBK7LRPPDIDK3JTNTCZZ7NUBBRDTVSK2'; + + it('should report a signer-only setOptions so it does not read as an empty accountConfig', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setOptions({ signer: { ed25519PublicKey: signerAddress, weight: 3 } }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + explanation.operations[0].type.should.equal('accountConfig'); + explanation.operations[0].signer.should.deepEqual({ + type: 'ed25519PublicKey', + key: signerAddress, + weight: 3, + }); + }); + + it('should report a signer removal as weight 0', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setOptions({ signer: { ed25519PublicKey: signerAddress, weight: 0 } }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + // weight 0 is the removal encoding and must not be dropped as falsy. + explanation.operations[0].signer.weight.should.equal(0); + }); + + it('should hex-encode hash and pre-auth transaction signers', async () => { + const hashSignerBytes = Buffer.alloc(32, 7); + const preAuthSignerBytes = Buffer.alloc(32, 9); + const txBase64 = buildTxBase64([ + stellar.Operation.setOptions({ signer: { sha256Hash: hashSignerBytes, weight: 1 } }), + stellar.Operation.setOptions({ signer: { preAuthTx: preAuthSignerBytes, weight: 2 } }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + explanation.operations[0].signer.should.deepEqual({ + type: 'sha256Hash', + key: hashSignerBytes.toString('hex'), + weight: 1, + }); + explanation.operations[1].signer.should.deepEqual({ + type: 'preAuthTx', + key: preAuthSignerBytes.toString('hex'), + weight: 2, + }); + }); + + it('should leave signer undefined when the setOptions does not carry one', async () => { + const txBase64 = buildTxBase64([stellar.Operation.setOptions({ masterWeight: 0 })]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + should.not.exist(explanation.operations[0].signer); + }); + }); + }); + + it('should include an unsupported entry for an operation type explain does not model', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.clawback({ from: trustorAddress, asset: bstAsset, amount: '1' }), + stellar.Operation.bumpSequence({ bumpTo: '100' }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + // A signed operation must never be missing from the explanation, or a reviewer cannot see it. + explanation.operations.length.should.equal(2); + explanation.operations[0].type.should.equal('clawback'); + explanation.operations[1].type.should.equal('unsupported'); + explanation.operations[1].operationType.should.equal('bumpSequence'); + }); + + it('should not add unsupported entries for payment and trustline operations explain already models', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.payment({ destination: trustorAddress, asset: stellar.Asset.native(), amount: '1' }), + stellar.Operation.changeTrust({ asset: bstAsset, limit: '1000' }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + // payment is reported through outputs, changeTrust through operations; neither is "unsupported". + explanation.outputs.length.should.equal(1); + explanation.operations.map((operation) => operation.type).should.deepEqual(['changeTrust']); + }); + + it('should report outputs and exactly one unsupported entry, in order, for a payment mixed with an unmodelled op', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.payment({ destination: trustorAddress, asset: stellar.Asset.native(), amount: '1' }), + stellar.Operation.bumpSequence({ bumpTo: '100' }), + stellar.Operation.clawback({ from: trustorAddress, asset: bstAsset, amount: '1' }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + explanation.outputs.length.should.equal(1); + // Operations keep transaction order, so a reviewer can line them up with the signed transaction. + explanation.operations.map((operation) => operation.type).should.deepEqual(['unsupported', 'clawback']); + explanation.operations[0].operationType.should.equal('bumpSequence'); + }); + + describe('operation source', () => { + const otherSourceAddress = 'GAFVCOWZWSJEAFOKBEBO2B4QITJ2YXN6YIYG6BUURQINVDVW4OPS3OL6'; + + it('should report an operation-level source on every operation kind that names one', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setOptions({ source: otherSourceAddress, masterWeight: 0 }), + stellar.Operation.setTrustLineFlags({ + source: otherSourceAddress, + trustor: trustorAddress, + asset: bstAsset, + flags: { authorized: true }, + }), + stellar.Operation.clawback({ + source: otherSourceAddress, + from: trustorAddress, + asset: bstAsset, + amount: '1', + }), + stellar.Operation.changeTrust({ source: otherSourceAddress, asset: bstAsset, limit: '1000' }), + stellar.Operation.bumpSequence({ source: otherSourceAddress, bumpTo: '100' }), + stellar.Operation.payment({ + source: otherSourceAddress, + destination: trustorAddress, + asset: stellar.Asset.native(), + amount: '1', + }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + // An op that runs against another account would otherwise read as if it applied to the tx source. + explanation.operations.length.should.equal(5); + explanation.operations.forEach((operation) => operation.source.should.equal(otherSourceAddress)); + explanation.outputs[0].source.should.equal(otherSourceAddress); + }); + + it('should leave source undefined when an operation does not name one', async () => { + const txBase64 = buildTxBase64([ + stellar.Operation.setOptions({ masterWeight: 0 }), + stellar.Operation.payment({ destination: trustorAddress, asset: stellar.Asset.native(), amount: '1' }), + ]); + const explanation = await basecoin.explainTransaction({ txBase64 }); + should.not.exist(explanation.operations[0].source); + should.not.exist(explanation.outputs[0].source); + }); + }); }); });