diff --git a/CHANGELOG.md b/CHANGELOG.md index dd62edbb9..bb5544fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added +* Implemented `TransactionBuilder.addSacTransferOperation` to remove the need for simulation for SAC (Stellar Asset Contracts) transfers by creating the appropriate auth entries and footprint ([#861](https://github.com/stellar/js-stellar-base/pull/861)). + +### Fixed +* `TransactionBuilder.build` now adds `this.sorobanData.resourceFee()` to `baseFee` when provided ([#861](https://github.com/stellar/js-stellar-base/pull/861)). ## [`v14.0.4`](https://github.com/stellar/js-stellar-base/compare/v14.0.3...v14.0.4): diff --git a/src/transaction_builder.js b/src/transaction_builder.js index cf90d23ff..48fe454d9 100644 --- a/src/transaction_builder.js +++ b/src/transaction_builder.js @@ -1,4 +1,4 @@ -import { UnsignedHyper } from '@stellar/js-xdr'; +import { UnsignedHyper, Hyper } from '@stellar/js-xdr'; import BigNumber from './util/bignumber'; import xdr from './xdr'; @@ -14,6 +14,12 @@ import { SorobanDataBuilder } from './sorobandata_builder'; import { StrKey } from './strkey'; import { SignerKey } from './signerkey'; import { Memo } from './memo'; +// eslint-disable-next-line no-unused-vars +import { Asset } from './asset'; +import { nativeToScVal } from './scval'; +import { Operation } from './operation'; +import { Address } from './address'; +import { Keypair } from './keypair'; /** * Minimum base fee for transactions. If this fee is below the network @@ -34,6 +40,14 @@ export const BASE_FEE = '100'; // Stroops */ export const TimeoutInfinite = 0; +/** + * @typedef {object} SorobanFees + * @property {number} instructions - the number of instructions executed by the transaction + * @property {number} readBytes - the number of bytes read from the ledger by the transaction + * @property {number} writeBytes - the number of bytes written to the ledger by the transaction + * @property {bigint} resourceFee - the fee to be paid for the transaction, in stroops + */ + /** *
Transaction builder helps constructs a new `{@link Transaction}` using the * given {@link Account} as the transaction's "source account". The transaction @@ -576,6 +590,204 @@ export class TransactionBuilder { return this; } + /** + * Creates and adds an invoke host function operation for transferring SAC tokens. + * This method removes the need for simulation by handling the creation of the + * appropriate authorization entries and ledger footprint for the transfer operation. + * + * @param {string} destination - the address of the recipient of the SAC transfer (should be a valid Stellar address or contract ID) + * @param {Asset} asset - the SAC asset to be transferred + * @param {BigInt} amount - the amount of tokens to be transferred in 7 decimals. IE 1 token with 7 decimals of precision would be represented as "1_0000000" + * @param {SorobanFees} [sorobanFees] - optional Soroban fees for the transaction to override the default fees used + * + * @returns {TransactionBuilder} + */ + addSacTransferOperation(destination, asset, amount, sorobanFees) { + if (BigInt(amount) <= 0n) { + throw new Error('Amount must be a positive integer'); + } else if (BigInt(amount) > Hyper.MAX_VALUE) { + // The largest supported value for SAC is i64 however the contract interface uses i128 which is why we convert it to i128 + throw new Error('Amount exceeds maximum value for i64'); + } + + if (sorobanFees) { + const { instructions, readBytes, writeBytes, resourceFee } = sorobanFees; + const U32_MAX = 4294967295; + + if (instructions <= 0 || instructions > U32_MAX) { + throw new Error( + `instructions must be greater than 0 and at most ${U32_MAX}` + ); + } + if (readBytes <= 0 || readBytes > U32_MAX) { + throw new Error( + `readBytes must be greater than 0 and at most ${U32_MAX}` + ); + } + if (writeBytes <= 0 || writeBytes > U32_MAX) { + throw new Error( + `writeBytes must be greater than 0 and at most ${U32_MAX}` + ); + } + if (resourceFee <= 0n || resourceFee > Hyper.MAX_VALUE) { + throw new Error( + 'resourceFee must be greater than 0 and at most i64 max' + ); + } + } + + const isDestinationContract = StrKey.isValidContract(destination); + if (!isDestinationContract) { + if ( + !StrKey.isValidEd25519PublicKey(destination) && + !StrKey.isValidMed25519PublicKey(destination) + ) { + throw new Error( + 'Invalid destination address. Must be a valid Stellar address or contract ID.' + ); + } + } + + if (destination === this.source.accountId()) { + throw new Error('Destination cannot be the same as the source account.'); + } + + const contractId = asset.contractId(this.networkPassphrase); + const functionName = 'transfer'; + const source = this.source.accountId(); + const args = [ + nativeToScVal(source, { type: 'address' }), + nativeToScVal(destination, { type: 'address' }), + nativeToScVal(amount, { type: 'i128' }) + ]; + const isAssetNative = asset.isNative(); + + const auths = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(contractId).toScAddress(), + functionName, + args + }) + ), + subInvocations: [] + }) + }); + + const footprint = new xdr.LedgerFootprint({ + readOnly: [ + xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: Address.fromString(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent() + }) + ) + ], + readWrite: [] + }); + + // Ledger entries for the destination account + if (isDestinationContract) { + footprint.readWrite().push( + xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: Address.fromString(contractId).toScAddress(), + key: xdr.ScVal.scvVec([ + nativeToScVal('Balance', { type: 'symbol' }), + nativeToScVal(destination, { type: 'address' }) + ]), + durability: xdr.ContractDataDurability.persistent() + }) + ) + ); + + if (!isAssetNative) { + footprint.readOnly().push( + xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: Keypair.fromPublicKey(asset.getIssuer()).xdrPublicKey() + }) + ) + ); + } + } else if (isAssetNative) { + footprint.readWrite().push( + xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: Keypair.fromPublicKey(destination).xdrPublicKey() + }) + ) + ); + } else if (asset.getIssuer() !== destination) { + footprint.readWrite().push( + xdr.LedgerKey.trustline( + new xdr.LedgerKeyTrustLine({ + accountId: Keypair.fromPublicKey(destination).xdrPublicKey(), + asset: asset.toTrustLineXDRObject() + }) + ) + ); + } + + // Ledger entries for the source account + if (asset.isNative()) { + footprint.readWrite().push( + xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: Keypair.fromPublicKey(source).xdrPublicKey() + }) + ) + ); + } else if (asset.getIssuer() !== source) { + footprint.readWrite().push( + xdr.LedgerKey.trustline( + new xdr.LedgerKeyTrustLine({ + accountId: Keypair.fromPublicKey(source).xdrPublicKey(), + asset: asset.toTrustLineXDRObject() + }) + ) + ); + } + + const defaultPaymentFees = { + instructions: 400_000, + readBytes: 1_000, + writeBytes: 1_000, + resourceFee: BigInt(5_000_000) + }; + + const sorobanData = new xdr.SorobanTransactionData({ + resources: new xdr.SorobanResources({ + footprint, + instructions: sorobanFees + ? sorobanFees.instructions + : defaultPaymentFees.instructions, + diskReadBytes: sorobanFees + ? sorobanFees.readBytes + : defaultPaymentFees.readBytes, + writeBytes: sorobanFees + ? sorobanFees.writeBytes + : defaultPaymentFees.writeBytes + }), + ext: new xdr.SorobanTransactionDataExt(0), + resourceFee: new xdr.Int64( + sorobanFees ? sorobanFees.resourceFee : defaultPaymentFees.resourceFee + ) + }); + const operation = Operation.invokeContractFunction({ + contract: contractId, + function: functionName, + args, + auth: [auths] + }); + this.setSorobanData(sorobanData); + return this.addOperation(operation); + } + /** * This will build the transaction. * It will also increment the source account's sequence number by 1. @@ -662,6 +874,10 @@ export class TransactionBuilder { if (this.sorobanData) { // @ts-ignore attrs.ext = new xdr.TransactionExt(1, this.sorobanData); + // Soroban transactions pay the resource fee in addition to the regular fee, so we need to add it here. + attrs.fee = new BigNumber(attrs.fee) + .plus(this.sorobanData.resourceFee()) + .toNumber(); } else { // @ts-ignore attrs.ext = new xdr.TransactionExt(0, xdr.Void); @@ -723,7 +939,6 @@ export class TransactionBuilder { const innerOps = innerTx.operations.length; const minBaseFee = new BigNumber(BASE_FEE); - let innerInclusionFee = new BigNumber(innerTx.fee).div(innerOps); let resourceFee = new BigNumber(0); // Do we need to do special Soroban fee handling? We only want the fee-bump @@ -733,16 +948,15 @@ export class TransactionBuilder { case xdr.EnvelopeType.envelopeTypeTx().value: { const sorobanData = env.v1().tx().ext().value(); resourceFee = new BigNumber(sorobanData?.resourceFee() ?? 0); - innerInclusionFee = BigNumber.max( - minBaseFee, - innerInclusionFee.minus(resourceFee) - ); + break; } default: break; } - + const innerInclusionFee = new BigNumber(innerTx.fee) + .minus(resourceFee) + .div(innerOps); const base = new BigNumber(baseFee); // The fee rate for fee bump is at least the fee rate of the inner transaction diff --git a/test/unit/transaction_builder_test.js b/test/unit/transaction_builder_test.js index f6a47ee79..b497e025c 100644 --- a/test/unit/transaction_builder_test.js +++ b/test/unit/transaction_builder_test.js @@ -159,7 +159,7 @@ describe('TransactionBuilder', function () { .build(); let transaction = new StellarBase.TransactionBuilder(source, { - fee: 620 /* assume BASE_FEE*2 + 420 resource fee */, + fee: 200 /* assume BASE_FEE*2 */, networkPassphrase: StellarBase.Networks.TESTNET }) .addOperation( @@ -176,7 +176,7 @@ describe('TransactionBuilder', function () { ) .setSorobanData(sorobanTransactionData) .setTimeout(StellarBase.TimeoutInfinite) - .build(); + .build(); // Building includes resource fee in the total fee expect( transaction.toEnvelope().v1().tx().ext().sorobanData().toXDR('base64') @@ -226,6 +226,498 @@ describe('TransactionBuilder', function () { }); }); + describe('addSacTransferOperation', function () { + const { xdr } = StellarBase; + const networkPassphrase = StellarBase.Networks.TESTNET; + + const SOURCE_ACCOUNT = + 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ'; + const DESTINATION_ACCOUNT = + 'GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2'; + const DESTINATION_CONTRACT = + 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + const ISSUER_ACCOUNT = + 'GC6ACGSA2NJGD6YWUNX2BYBL3VM4MZRSEU2RLIUZZL35NLV5IAHAX2E2'; + + let source; + + beforeEach(function () { + source = new StellarBase.Account(SOURCE_ACCOUNT, '0'); + }); + + function buildSacTx(destination, asset) { + return new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }) + .addSacTransferOperation(destination, asset, '10') + .setTimeout(StellarBase.TimeoutInfinite) + .build(); + } + + function contractAddressFromId(contractId) { + return StellarBase.Address.fromString(contractId).toScAddress(); + } + + function ledgerKeyContractInstance(contractId) { + return new xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contractAddressFromId(contractId), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent() + }) + ); + } + + function ledgerKeyAccount(accountId) { + return new xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ + accountId: StellarBase.Keypair.fromPublicKey(accountId).xdrPublicKey() + }) + ); + } + + function ledgerKeyTrustline(accountId, asset) { + return new xdr.LedgerKey.trustline( + new xdr.LedgerKeyTrustLine({ + accountId: + StellarBase.Keypair.fromPublicKey(accountId).xdrPublicKey(), + asset: asset.toTrustLineXDRObject() + }) + ); + } + + describe('authorization', function () { + it('creates a source-account-credentialed authorization for the transfer', function () { + const asset = new StellarBase.Asset('TEST', ISSUER_ACCOUNT); + const tx = buildSacTx(DESTINATION_ACCOUNT, asset); + + expect(tx.operations).to.have.length(1); + + // auth is on the decoded operation + const auths = tx.operations[0].auth; + expect(auths).to.have.length(1); + + const auth = auths[0]; + + // credentials: must be source-account (no explicit signature required) + expect(auth.credentials().switch()).to.eql( + xdr.SorobanCredentialsType.sorobanCredentialsSourceAccount() + ); + + const rootInvoc = auth.rootInvocation(); + + // function type: contract function + expect(rootInvoc.function().switch()).to.eql( + xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn() + ); + + // contract address matches the asset's SAC contract + const contractId = asset.contractId(networkPassphrase); + const contractFn = rootInvoc.function().contractFn(); + expect(contractFn.contractAddress().toXDR('base64')).to.equal( + StellarBase.Address.fromString(contractId) + .toScAddress() + .toXDR('base64') + ); + + // function name is 'transfer' + expect( + Buffer.from(contractFn.functionName()).toString('utf8') + ).to.equal('transfer'); + + // args: [source address, destination address, amount as i128] + const args = contractFn.args(); + expect(args).to.have.length(3); + expect(args[0].toXDR('base64')).to.equal( + StellarBase.nativeToScVal(SOURCE_ACCOUNT, { type: 'address' }).toXDR( + 'base64' + ) + ); + expect(args[1].toXDR('base64')).to.equal( + StellarBase.nativeToScVal(DESTINATION_ACCOUNT, { + type: 'address' + }).toXDR('base64') + ); + expect(args[2].toXDR('base64')).to.equal( + StellarBase.nativeToScVal('10', { type: 'i128' }).toXDR('base64') + ); + + // no sub-invocations + expect(rootInvoc.subInvocations()).to.have.length(0); + }); + }); + + describe('native asset footprint', function () { + let asset; + let contractId; + + beforeEach(function () { + asset = StellarBase.Asset.native(); + contractId = asset.contractId(networkPassphrase); + }); + + it('destination contract: writes balance contractData and source account', function () { + const tx = buildSacTx(DESTINATION_CONTRACT, asset); + + expect(tx.toEnvelope().v1().tx().ext().switch()).to.equal(1); + + const sorobanData = tx.toEnvelope().v1().tx().ext().sorobanData(); + const footprint = sorobanData.resources().footprint(); + + const expectedReadOnly = [ledgerKeyContractInstance(contractId)]; + const expectedReadWrite = [ + new xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contractAddressFromId(contractId), + key: xdr.ScVal.scvVec([ + StellarBase.nativeToScVal('Balance', { type: 'symbol' }), + StellarBase.nativeToScVal(DESTINATION_CONTRACT, { + type: 'address' + }) + ]), + durability: xdr.ContractDataDurability.persistent() + }) + ), + ledgerKeyAccount(SOURCE_ACCOUNT) + ]; + + expect(footprint.readOnly().map((k) => k.toXDR('base64'))).to.eql( + expectedReadOnly.map((k) => k.toXDR('base64')) + ); + expect(footprint.readWrite().map((k) => k.toXDR('base64'))).to.eql( + expectedReadWrite.map((k) => k.toXDR('base64')) + ); + }); + + it('destination account: writes destination and source accounts', function () { + const tx = buildSacTx(DESTINATION_ACCOUNT, asset); + + const sorobanData = tx.toEnvelope().v1().tx().ext().sorobanData(); + const footprint = sorobanData.resources().footprint(); + + const expectedReadOnly = [ledgerKeyContractInstance(contractId)]; + const expectedReadWrite = [ + ledgerKeyAccount(DESTINATION_ACCOUNT), + ledgerKeyAccount(SOURCE_ACCOUNT) + ]; + expect(footprint.readOnly().map((k) => k.toXDR('base64'))).to.eql( + expectedReadOnly.map((k) => k.toXDR('base64')) + ); + expect(footprint.readWrite().map((k) => k.toXDR('base64'))).to.eql( + expectedReadWrite.map((k) => k.toXDR('base64')) + ); + }); + }); + + describe('credit asset footprint', function () { + let asset; + let contractId; + + beforeEach(function () { + asset = new StellarBase.Asset('TEST', ISSUER_ACCOUNT); + contractId = asset.contractId(networkPassphrase); + }); + + it('destination account: writes destination and source trustlines', function () { + const tx = buildSacTx(DESTINATION_ACCOUNT, asset); + + const sorobanData = tx.toEnvelope().v1().tx().ext().sorobanData(); + const footprint = sorobanData.resources().footprint(); + + const expectedReadOnly = [ledgerKeyContractInstance(contractId)]; + const expectedReadWrite = [ + ledgerKeyTrustline(DESTINATION_ACCOUNT, asset), + ledgerKeyTrustline(SOURCE_ACCOUNT, asset) + ]; + expect(footprint.readOnly().map((k) => k.toXDR('base64'))).to.eql( + expectedReadOnly.map((k) => k.toXDR('base64')) + ); + expect(footprint.readWrite().map((k) => k.toXDR('base64'))).to.eql( + expectedReadWrite.map((k) => k.toXDR('base64')) + ); + }); + + it('destination contract: reads issuer account and writes source trustline', function () { + const tx = buildSacTx(DESTINATION_CONTRACT, asset); + + const sorobanData = tx.toEnvelope().v1().tx().ext().sorobanData(); + const footprint = sorobanData.resources().footprint(); + + const expectedReadOnly = [ + ledgerKeyContractInstance(contractId), + ledgerKeyAccount(ISSUER_ACCOUNT) + ]; + const expectedReadWrite = [ + new xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contractAddressFromId(contractId), + key: xdr.ScVal.scvVec([ + StellarBase.nativeToScVal('Balance', { type: 'symbol' }), + StellarBase.nativeToScVal(DESTINATION_CONTRACT, { + type: 'address' + }) + ]), + durability: xdr.ContractDataDurability.persistent() + }) + ), + ledgerKeyTrustline(SOURCE_ACCOUNT, asset) + ]; + expect(footprint.readOnly().map((k) => k.toXDR('base64'))).to.eql( + expectedReadOnly.map((k) => k.toXDR('base64')) + ); + expect(footprint.readWrite().map((k) => k.toXDR('base64'))).to.eql( + expectedReadWrite.map((k) => k.toXDR('base64')) + ); + }); + + it('destination is issuer: omits destination trustline', function () { + const tx = buildSacTx(ISSUER_ACCOUNT, asset); + + const sorobanData = tx.toEnvelope().v1().tx().ext().sorobanData(); + const footprint = sorobanData.resources().footprint(); + + const expectedReadOnly = [ledgerKeyContractInstance(contractId)]; + const expectedReadWrite = [ledgerKeyTrustline(SOURCE_ACCOUNT, asset)]; + + expect(footprint.readOnly().map((k) => k.toXDR('base64'))).to.eql( + expectedReadOnly.map((k) => k.toXDR('base64')) + ); + expect(footprint.readWrite().map((k) => k.toXDR('base64'))).to.eql( + expectedReadWrite.map((k) => k.toXDR('base64')) + ); + }); + + it('source is issuer: omits source trustline', function () { + source = new StellarBase.Account(ISSUER_ACCOUNT, '0'); + const tx = buildSacTx(DESTINATION_ACCOUNT, asset); + + const sorobanData = tx.toEnvelope().v1().tx().ext().sorobanData(); + const footprint = sorobanData.resources().footprint(); + + const expectedReadOnly = [ledgerKeyContractInstance(contractId)]; + const expectedReadWrite = [ + ledgerKeyTrustline(DESTINATION_ACCOUNT, asset) + ]; + + expect(footprint.readOnly().map((k) => k.toXDR('base64'))).to.eql( + expectedReadOnly.map((k) => k.toXDR('base64')) + ); + expect(footprint.readWrite().map((k) => k.toXDR('base64'))).to.eql( + expectedReadWrite.map((k) => k.toXDR('base64')) + ); + }); + }); + + describe('input validation', function () { + it('rejects amount greater than i64 max', function () { + const asset = StellarBase.Asset.native(); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation( + DESTINATION_ACCOUNT, + asset, + '9223372036854775808' + ); + }).to.throw(/Amount exceeds maximum value for i64/); + }); + + it('accepts amount equal to i64 max', function () { + const asset = StellarBase.Asset.native(); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation( + DESTINATION_ACCOUNT, + asset, + '9223372036854775807' + ); + }).to.not.throw(); + }); + + it('rejects amount of zero', function () { + const asset = StellarBase.Asset.native(); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }) + .addSacTransferOperation(DESTINATION_ACCOUNT, asset, '0') + .setTimeout(StellarBase.TimeoutInfinite) + .build(); + }).to.throw(/Amount must be a positive integer/); + }); + + it('rejects negative amount', function () { + const asset = StellarBase.Asset.native(); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '-1'); + }).to.throw(/Amount must be a positive integer/); + }); + + it('rejects destination equal to source account', function () { + const asset = StellarBase.Asset.native(); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(SOURCE_ACCOUNT, asset, '10'); + }).to.throw(/Destination cannot be the same as the source account/); + }); + + it('validates sorobanFees are greater than zero', function () { + const asset = StellarBase.Asset.native(); + const U32_MAX = 4294967295; + const validFees = { + instructions: 1, + readBytes: 2, + writeBytes: 3, + resourceFee: BigInt(4) + }; + + // each u32 field rejects 0 + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + instructions: 0 + }); + }).to.throw(/instructions must be greater than 0/); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + readBytes: 0 + }); + }).to.throw(/readBytes must be greater than 0/); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + writeBytes: 0 + }); + }).to.throw(/writeBytes must be greater than 0/); + + // resourceFee rejects 0 + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + resourceFee: BigInt(0) + }); + }).to.throw(/resourceFee must be greater than 0/); + }); + + it('rejects u32 fields exceeding u32 max', function () { + const asset = StellarBase.Asset.native(); + const U32_MAX = 4294967295; + const validFees = { + instructions: 1, + readBytes: 2, + writeBytes: 3, + resourceFee: BigInt(4) + }; + + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + instructions: U32_MAX + 1 + }); + }).to.throw(/instructions must be greater than 0/); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + readBytes: U32_MAX + 1 + }); + }).to.throw(/readBytes must be greater than 0/); + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + ...validFees, + writeBytes: U32_MAX + 1 + }); + }).to.throw(/writeBytes must be greater than 0/); + }); + + it('accepts u32 fields at u32 max', function () { + const asset = StellarBase.Asset.native(); + const U32_MAX = 4294967295; + + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }) + .addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + instructions: U32_MAX, + readBytes: U32_MAX, + writeBytes: U32_MAX, + resourceFee: BigInt(1) + }) + .setTimeout(StellarBase.TimeoutInfinite) + .build(); + }).to.not.throw(); + }); + + it('rejects resourceFee exceeding i64 max', function () { + const asset = StellarBase.Asset.native(); + + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }).addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + instructions: 1, + readBytes: 1, + writeBytes: 1, + resourceFee: BigInt('9223372036854775808') + }); + }).to.throw(/resourceFee must be greater than 0/); + }); + + it('accepts resourceFee at i64 max', function () { + const asset = StellarBase.Asset.native(); + + expect(() => { + new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase + }) + .addSacTransferOperation(DESTINATION_ACCOUNT, asset, '10', { + instructions: 1, + readBytes: 1, + writeBytes: 1, + resourceFee: BigInt('9223372036854775807') + }) + .setTimeout(StellarBase.TimeoutInfinite) + .build(); + }).to.not.throw(); + }); + }); + }); + describe('constructs a native payment transaction with two operations', function () { var source; var destination1; diff --git a/types/index.d.ts b/types/index.d.ts index 70ad904ef..1977b29af 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1062,6 +1062,17 @@ export class Transaction< export const BASE_FEE = '100'; export const TimeoutInfinite = 0; +/** + * Represents the fees associated with a Soroban transaction, including the number of instructions executed, + * the number of bytes read and written to the ledger, and the total resource fee in stroops. + */ +export interface SorobanFees { + instructions: number; + readBytes: number; + writeBytes: number; + resourceFee: bigint; +} + export class TransactionBuilder { constructor( sourceAccount: Account, @@ -1080,6 +1091,19 @@ export class TransactionBuilder { setMinAccountSequenceLedgerGap(gap: number): this; setExtraSigners(extraSigners: string[]): this; setSorobanData(sorobanData: string | xdr.SorobanTransactionData): this; + /** + * Creates and adds an invoke host function operation for transferring SAC tokens. + * This method removes the need for simulation by handling the creation of the + * appropriate authorization entries and ledger footprint for the transfer operation. + * + * @param destination - the address of the recipient of the SAC transfer (should be a valid Stellar address or contract ID) + * @param asset - the SAC asset to be transferred + * @param amount - the amount of tokens to be transferred in 7 decimals. IE 1 token with 7 decimals of precision would be represented as "1_0000000" + * @param sorobanFees - optional Soroban fees for the transaction to override the default fees used + * + * @returns the TransactionBuilder instance with the SAC transfer operation added + */ + addSacTransferOperation(destination: string, asset: Asset, amount: string | bigint, sorobanFees?: SorobanFees): this; build(): Transaction; setNetworkPassphrase(networkPassphrase: string): this;