diff --git a/modules/sdk-coin-xlm/src/xlm.ts b/modules/sdk-coin-xlm/src/xlm.ts index 02d56a71c7..0ca30113a9 100644 --- a/modules/sdk-coin-xlm/src/xlm.ts +++ b/modules/sdk-coin-xlm/src/xlm.ts @@ -121,9 +121,13 @@ interface TransactionMemo { interface TransactionOperation { type: string; - coin: string; + coin?: string; limit?: string; asset?: stellar.Asset; + setFlags?: number; + clearFlags?: number; + trustor?: string; + flags?: Record; } interface TransactionOutput extends BaseTransactionOutput { @@ -144,8 +148,42 @@ interface TrustlineOptions { limit?: string; } +/** + * Subset of Stellar account flags that can be toggled via setOptions. + * ImmutableFlag is intentionally excluded — it is irreversible. + */ +interface XlmAccountFlags { + authRequired?: boolean; + authRevocable?: boolean; + authClawbackEnabled?: boolean; +} + +/** + * Account flag configuration using explicit setFlags/clearFlags objects. + * Mirrors the Stellar setOptions operation — a flag in setFlags is enabled, + * a flag in clearFlags is disabled, absent flags are left unchanged. + * A flag must not appear in both setFlags and clearFlags simultaneously. + */ +interface AccountConfigOptions { + setFlags?: XlmAccountFlags; + clearFlags?: XlmAccountFlags; +} + +/** + * Options for issuer-side trustline authorization via setTrustLineFlags. + * authorized = true grants permission; false revokes it (freezes the holder's balance). + */ +interface AuthorizeTrustlineOptions { + trustorAddress: string; + assetCode: string; + assetIssuer: string; + authorized: boolean; +} + interface TransactionParams extends BaseTransactionParams { trustlines?: TrustlineOptions[]; + accountConfig?: AccountConfigOptions; + authorizeTrustline?: AuthorizeTrustlineOptions; } interface VerifyTransactionOptions extends BaseVerifyTransactionOptions { @@ -620,12 +658,17 @@ export class Xlm extends BaseCoin { } /** - * Get extra parameters for prebuilding a tx - * Set empty recipients array in trustline txs + * Get extra parameters for prebuilding a tx. + * Trustline, accountConfig, and authorizeTrustline txs carry no recipients. */ async getExtraPrebuildParams(buildParams: ExtraPrebuildParamsOptions): Promise { const params: { recipients?: Record[] } = {}; - if (buildParams.type === 'trustline') { + if ( + buildParams.type === 'trustline' || + buildParams.type === 'accountConfig' || + buildParams.type === 'authorizeTrustline' + ) { + // These transaction types operate on account or trustline state — no fund recipients. params.recipients = []; } return params; @@ -961,6 +1004,19 @@ export class Xlm extends BaseCoin { asset, limit: this.bigUnitsToBaseUnits(op.limit), }); + } else if (op.type === 'setOptions') { + operations.push({ + type: op.type, + setFlags: op.setFlags, + clearFlags: op.clearFlags, + }); + } else if (op.type === 'setTrustLineFlags') { + operations.push({ + type: op.type, + trustor: op.trustor, + asset: op.asset, + flags: op.flags, + }); } }); @@ -1066,6 +1122,91 @@ export class Xlm extends BaseCoin { }); } + /** + * Verify that an accountConfig transaction contains exactly one setOptions operation + * whose setFlags/clearFlags bitmasks match the requested flag changes. + * + * Stellar flag bitmask values: + * AuthRequiredFlag = 0x1 + * AuthRevocableFlag = 0x2 + * AuthClawbackEnabledFlag = 0x8 + */ + verifyAccountConfigTxOperations(operations: stellar.Operation[], txParams: TransactionParams): void { + if (!txParams.accountConfig) { + throw new Error('accountConfig txParams missing accountConfig field'); + } + const setOptionsOps = operations.filter((op) => op.type === 'setOptions'); + if (setOptionsOps.length !== 1) { + throw new Error( + `accountConfig transaction must have exactly 1 setOptions operation, got ${setOptionsOps.length}` + ); + } + const op = setOptionsOps[0] as stellar.Operation.SetOptions; + const { setFlags: flagsToSet, clearFlags: flagsToClear } = txParams.accountConfig; + + // Build expected bitmasks from the structured setFlags/clearFlags objects. + let expectedSetMask = 0; + let expectedClearMask = 0; + if (flagsToSet?.authRequired) expectedSetMask |= stellar.AuthRequiredFlag; + if (flagsToSet?.authRevocable) expectedSetMask |= stellar.AuthRevocableFlag; + if (flagsToSet?.authClawbackEnabled) expectedSetMask |= stellar.AuthClawbackEnabledFlag; + if (flagsToClear?.authRequired) expectedClearMask |= stellar.AuthRequiredFlag; + if (flagsToClear?.authRevocable) expectedClearMask |= stellar.AuthRevocableFlag; + if (flagsToClear?.authClawbackEnabled) expectedClearMask |= stellar.AuthClawbackEnabledFlag; + + const actualSetMask = (op.setFlags ?? 0) as number; + const actualClearMask = (op.clearFlags ?? 0) as number; + if (actualSetMask !== expectedSetMask) { + throw new Error( + `accountConfig setFlags mismatch: expected 0x${expectedSetMask.toString(16)}, got 0x${actualSetMask.toString( + 16 + )}` + ); + } + if (actualClearMask !== expectedClearMask) { + throw new Error( + `accountConfig clearFlags mismatch: expected 0x${expectedClearMask.toString( + 16 + )}, got 0x${actualClearMask.toString(16)}` + ); + } + } + + /** + * Verify that an authorizeTrustline transaction contains exactly one setTrustLineFlags + * operation targeting the expected trustor, asset, and authorization state. + */ + verifyAuthorizeTrustlineTxOperations(operations: stellar.Operation[], txParams: TransactionParams): void { + if (!txParams.authorizeTrustline) { + throw new Error('authorizeTrustline txParams missing authorizeTrustline field'); + } + const authOps = operations.filter((op) => op.type === 'setTrustLineFlags'); + if (authOps.length !== 1) { + throw new Error( + `authorizeTrustline transaction must have exactly 1 setTrustLineFlags operation, got ${authOps.length}` + ); + } + const op = authOps[0] as stellar.Operation.SetTrustLineFlags; + const { trustorAddress, assetCode, assetIssuer, authorized } = txParams.authorizeTrustline; + + if (op.trustor !== trustorAddress) { + throw new Error(`authorizeTrustline trustor mismatch: expected ${trustorAddress}, got ${op.trustor}`); + } + const asset = op.asset as stellar.Asset; + if (asset.getCode() !== assetCode) { + throw new Error(`authorizeTrustline asset code mismatch: expected ${assetCode}, got ${asset.getCode()}`); + } + if (asset.getIssuer() !== assetIssuer) { + throw new Error(`authorizeTrustline asset issuer mismatch: expected ${assetIssuer}, got ${asset.getIssuer()}`); + } + // The Stellar SDK decodes setTrustLineFlags flags as a plain object { authorized?: boolean, ... } + const flagsObj = op.flags as { authorized?: boolean }; + const actualAuthorized = flagsObj?.authorized ?? false; + if (actualAuthorized !== authorized) { + throw new Error(`authorizeTrustline authorized flag mismatch: expected ${authorized}, got ${actualAuthorized}`); + } + } + getRecipientOrThrow(txParams: TransactionParams): ITransactionRecipient { if (!txParams.recipients || txParams.recipients.length === 0) throw new Error('Missing recipients on token enablement'); @@ -1199,6 +1340,10 @@ export class Xlm extends BaseCoin { this.verifyTokenLimits(txParams, trustlineOperations); } else if (txParams.type === 'trustline') { this.verifyTrustlineTxOperations(tx.operations, txParams); + } else if (txParams.type === 'accountConfig') { + this.verifyAccountConfigTxOperations(tx.operations, txParams); + } else if (txParams.type === 'authorizeTrustline') { + this.verifyAuthorizeTrustlineTxOperations(tx.operations, txParams); } else { if (_.isEmpty(outputOperations)) { throw new Error('transaction prebuild does not have any operations'); diff --git a/modules/sdk-coin-xlm/test/unit/xlm.ts b/modules/sdk-coin-xlm/test/unit/xlm.ts index b48ac1c02a..c90cc8bdda 100644 --- a/modules/sdk-coin-xlm/test/unit/xlm.ts +++ b/modules/sdk-coin-xlm/test/unit/xlm.ts @@ -250,6 +250,71 @@ describe('XLM:', function () { explanation.operations[0].asset.issuer.should.equal('GBQTIOS3XGHB7LVYGBKQVJGCZ3R4JL5E4CBSWJ5ALIJUHBKS6263644L'); }); + it('Should explain a setOptions (accountConfig) transaction', async function () { + // Build a setOptions tx that sets authRequired (0x1) and clears authClawbackEnabled (0x8). + const keypair = stellar.Keypair.fromSecret('SAQDKA5TZKTP2XZ67ZZ5MWDR7EJZFBAGVMRYITKEL5SABJ3ZTJN5WMWQ'); + const account = new stellar.Account(keypair.publicKey(), '0'); + const txBase64 = new stellar.TransactionBuilder(account, { + fee: '100', + networkPassphrase: stellar.Networks.TESTNET, + }) + .addOperation( + stellar.Operation.setOptions({ + setFlags: stellar.AuthRequiredFlag as stellar.AuthFlag, + clearFlags: stellar.AuthClawbackEnabledFlag as stellar.AuthFlag, + }) + ) + .setTimeout(0) + .build() + .toEnvelope() + .toXDR('base64'); + + const explanation = await basecoin.explainTransaction({ txBase64 }); + + explanation.outputAmount.should.equal('0'); + explanation.outputs.length.should.equal(0); + explanation.operations.length.should.equal(1); + // setFlags and clearFlags are decoded as numeric bitmasks by the Stellar SDK + explanation.operations[0].type.should.equal('setOptions'); + explanation.operations[0].setFlags.should.equal(stellar.AuthRequiredFlag); + explanation.operations[0].clearFlags.should.equal(stellar.AuthClawbackEnabledFlag); + }); + + it('Should explain a setTrustLineFlags (authorizeTrustline) transaction', async function () { + // Build a setTrustLineFlags tx granting authorization to a holder's trustline. + const issuer = stellar.Keypair.fromSecret('SAQDKA5TZKTP2XZ67ZZ5MWDR7EJZFBAGVMRYITKEL5SABJ3ZTJN5WMWQ'); + const holderAddress = 'GAJTPNROWXZN4ILJ7K3K3BAAKF7M3E7B3F2P6YTGMIFBW7OZX6KDBSBB'; + const asset = new stellar.Asset('BGTKN', issuer.publicKey()); + const account = new stellar.Account(issuer.publicKey(), '0'); + const txBase64 = new stellar.TransactionBuilder(account, { + fee: '100', + networkPassphrase: stellar.Networks.TESTNET, + }) + .addOperation( + stellar.Operation.setTrustLineFlags({ + trustor: holderAddress, + asset, + flags: { authorized: true }, + }) + ) + .setTimeout(0) + .build() + .toEnvelope() + .toXDR('base64'); + + const explanation = await basecoin.explainTransaction({ txBase64 }); + + explanation.outputAmount.should.equal('0'); + explanation.outputs.length.should.equal(0); + explanation.operations.length.should.equal(1); + explanation.operations[0].type.should.equal('setTrustLineFlags'); + explanation.operations[0].trustor.should.equal(holderAddress); + // The Stellar SDK decodes flags as a plain object after XDR round-trip + (explanation.operations[0].flags as any).authorized.should.equal(true); + (explanation.operations[0].asset as stellar.Asset).getCode().should.equal('BGTKN'); + (explanation.operations[0].asset as stellar.Asset).getIssuer().should.equal(issuer.publicKey()); + }); + it('Should explain a token transaction', async function () { const explanation = await basecoin.explainTransaction({ txBase64: @@ -1013,6 +1078,313 @@ describe('XLM:', function () { }); }); + describe('accountConfig transactions', function () { + // Issuer keypair used as the source account for all accountConfig test transactions. + const issuerKeypair = stellar.Keypair.fromSecret('SAQDKA5TZKTP2XZ67ZZ5MWDR7EJZFBAGVMRYITKEL5SABJ3ZTJN5WMWQ'); + const testnet = stellar.Networks.TESTNET; + + // wallet and keychains needed by verifyTransaction — reuse the same test fixtures + // as the Transaction Verification describe block so the key math stays consistent + let wallet; + const userKeychain = { + pub: 'GA34NPQ4M54HHZBKSDZ5B3J3BZHTXKCZD4UFO2OYZERPOASK4DAATSIB', + prv: 'SDADJSTZNIKF46NM7LE3ZHMX4TJ2VJBL7PTERNDLWHZ5U6KNO5S7XFJD', + }; + const backupKeychain = { + pub: 'GC3D3ZNNK7GHLMSWJA54DQO6QJUJJF7K6J5JGCEW45ZT6QMKZ6PMUHUM', + prv: 'SA22TDBINLZMGYUDVXGUP2JMYIQ3DTJE53PNQUVCDK73XRS6TDVYU7WW', + }; + + before(function () { + const walletData = { + id: '5a78dd561c6258a907f1eeaee132f796', + coin: 'txlm', + keys: [ + '5a78dd56bfe424aa07aa068651b194fd', + '5a78dd5674a70eb4079f58797dfe2f5e', + '5a78dd561c6258a907f1eea9f1d079e2', + ], + coinSpecific: {}, + }; + wallet = new Wallet(bitgo, basecoin, walletData); + }); + + /** + * Build a minimal setOptions transaction with the given setFlags/clearFlags bitmasks. + * Returns a txBase64 string suitable for use as a txPrebuild in verifyTransaction. + */ + function buildSetOptionsTx(setFlagsMask: number, clearFlagsMask: number): string { + const account = new stellar.Account(issuerKeypair.publicKey(), '0'); + const tx = new stellar.TransactionBuilder(account, { fee: '100', networkPassphrase: testnet }) + .addOperation( + stellar.Operation.setOptions({ + setFlags: setFlagsMask as stellar.AuthFlag, + clearFlags: clearFlagsMask as stellar.AuthFlag, + }) + ) + .setTimeout(0) + .build(); + return tx.toEnvelope().toXDR('base64'); + } + + it('should verify an accountConfig transaction that sets authRequired and authRevocable', async function () { + // Setting two flags simultaneously — bitmask 0x1 | 0x2 = 3 + const txBase64 = buildSetOptionsTx(stellar.AuthRequiredFlag | stellar.AuthRevocableFlag, 0); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'accountConfig', + recipients: [], + accountConfig: { + setFlags: { authRequired: true, authRevocable: true }, + }, + }; + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }); + result.should.equal(true); + }); + + it('should verify an accountConfig transaction that clears authClawbackEnabled', async function () { + // Clearing a single flag — bitmask 0x8 + const txBase64 = buildSetOptionsTx(0, stellar.AuthClawbackEnabledFlag); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'accountConfig', + recipients: [], + accountConfig: { + clearFlags: { authClawbackEnabled: true }, + }, + }; + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }); + result.should.equal(true); + }); + + it('should fail verification when setFlags bitmask does not match txParams', async function () { + // Transaction sets only authRequired (0x1) but txParams expects authRequired + authRevocable (0x3) + const txBase64 = buildSetOptionsTx(stellar.AuthRequiredFlag, 0); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'accountConfig', + recipients: [], + accountConfig: { + setFlags: { authRequired: true, authRevocable: true }, + }, + }; + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }) + .should.be.rejectedWith(/accountConfig setFlags mismatch/); + }); + + it('should fail verification when accountConfig field is missing from txParams', async function () { + const txBase64 = buildSetOptionsTx(stellar.AuthRequiredFlag, 0); + const txPrebuild = { txBase64 }; + const txParams = { type: 'accountConfig', recipients: [] }; + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }) + .should.be.rejectedWith(/accountConfig txParams missing accountConfig field/); + }); + }); + + describe('authorizeTrustline transactions', function () { + const issuerKeypair = stellar.Keypair.fromSecret('SAQDKA5TZKTP2XZ67ZZ5MWDR7EJZFBAGVMRYITKEL5SABJ3ZTJN5WMWQ'); + const holderAddress = 'GAJTPNROWXZN4ILJ7K3K3BAAKF7M3E7B3F2P6YTGMIFBW7OZX6KDBSBB'; + const testnet = stellar.Networks.TESTNET; + const assetCode = 'BGTKN'; + + let wallet; + const userKeychain = { + pub: 'GA34NPQ4M54HHZBKSDZ5B3J3BZHTXKCZD4UFO2OYZERPOASK4DAATSIB', + prv: 'SDADJSTZNIKF46NM7LE3ZHMX4TJ2VJBL7PTERNDLWHZ5U6KNO5S7XFJD', + }; + const backupKeychain = { + pub: 'GC3D3ZNNK7GHLMSWJA54DQO6QJUJJF7K6J5JGCEW45ZT6QMKZ6PMUHUM', + prv: 'SA22TDBINLZMGYUDVXGUP2JMYIQ3DTJE53PNQUVCDK73XRS6TDVYU7WW', + }; + + before(function () { + const walletData = { + id: '5a78dd561c6258a907f1eeaee132f796', + coin: 'txlm', + keys: [ + '5a78dd56bfe424aa07aa068651b194fd', + '5a78dd5674a70eb4079f58797dfe2f5e', + '5a78dd561c6258a907f1eea9f1d079e2', + ], + coinSpecific: {}, + }; + wallet = new Wallet(bitgo, basecoin, walletData); + }); + + /** + * Build a setTrustLineFlags transaction granting or revoking authorization. + * authorized=true sets AUTHORIZED_FLAG (1); authorized=false clears it. + */ + function buildSetTrustLineFlagsTx(authorized: boolean): string { + const asset = new stellar.Asset(assetCode, issuerKeypair.publicKey()); + const account = new stellar.Account(issuerKeypair.publicKey(), '0'); + const tx = new stellar.TransactionBuilder(account, { fee: '100', networkPassphrase: testnet }) + .addOperation( + stellar.Operation.setTrustLineFlags({ + trustor: holderAddress, + asset, + flags: { authorized }, + }) + ) + .setTimeout(0) + .build(); + return tx.toEnvelope().toXDR('base64'); + } + + it('should verify an authorizeTrustline transaction that grants authorization', async function () { + const txBase64 = buildSetTrustLineFlagsTx(true); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'authorizeTrustline', + recipients: [], + authorizeTrustline: { + trustorAddress: holderAddress, + assetCode, + assetIssuer: issuerKeypair.publicKey(), + authorized: true, + }, + }; + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }); + result.should.equal(true); + }); + + it('should verify an authorizeTrustline transaction that revokes authorization', async function () { + const txBase64 = buildSetTrustLineFlagsTx(false); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'authorizeTrustline', + recipients: [], + authorizeTrustline: { + trustorAddress: holderAddress, + assetCode, + assetIssuer: issuerKeypair.publicKey(), + authorized: false, + }, + }; + const result = await basecoin.verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }); + result.should.equal(true); + }); + + it('should fail verification when trustor address does not match', async function () { + const txBase64 = buildSetTrustLineFlagsTx(true); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'authorizeTrustline', + recipients: [], + authorizeTrustline: { + trustorAddress: 'GBIEJQUARJ33DIZU4AIRDOKYPSVK66Z3O5XU7OOI7LUOAJWTPI4OA4JI', // wrong trustor + assetCode, + assetIssuer: issuerKeypair.publicKey(), + authorized: true, + }, + }; + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }) + .should.be.rejectedWith(/authorizeTrustline trustor mismatch/); + }); + + it('should fail verification when asset code does not match', async function () { + const txBase64 = buildSetTrustLineFlagsTx(true); + const txPrebuild = { txBase64 }; + const txParams = { + type: 'authorizeTrustline', + recipients: [], + authorizeTrustline: { + trustorAddress: holderAddress, + assetCode: 'USDC', // wrong asset code + assetIssuer: issuerKeypair.publicKey(), + authorized: true, + }, + }; + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }) + .should.be.rejectedWith(/authorizeTrustline asset code mismatch/); + }); + + it('should fail verification when authorizeTrustline field is missing from txParams', async function () { + const txBase64 = buildSetTrustLineFlagsTx(true); + const txPrebuild = { txBase64 }; + const txParams = { type: 'authorizeTrustline', recipients: [] }; + await basecoin + .verifyTransaction({ + txParams, + txPrebuild, + wallet, + verification: { + disableNetworking: true, + keychains: { user: { pub: userKeychain.pub }, backup: { pub: backupKeychain.pub } }, + }, + }) + .should.be.rejectedWith(/authorizeTrustline txParams missing authorizeTrustline field/); + }); + }); + describe('Federation lookups:', function () { describe('Look up by stellar address:', function () { it('should fail to loop up an invalid stellar address with a bitgo.com domain', async function () { diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index a635b2057b..cccb690fa1 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -105,6 +105,8 @@ export const BuildParams = t.exact( sourceChain: t.unknown, destinationChain: t.unknown, trustlines: t.unknown, + accountConfig: t.unknown, + authorizeTrustline: t.unknown, type: t.unknown, limit: t.unknown, timeBounds: t.unknown,