Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
228 changes: 221 additions & 7 deletions src/transaction_builder.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ESLint complaining that we only use this import in the doc portion?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the ESLint rules does not know that its being used for the jsdoc

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
Expand All @@ -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
*/

/**
* <p>Transaction builder helps constructs a new `{@link Transaction}` using the
* given {@link Account} as the transaction's "source account". The transaction
Expand Down Expand Up @@ -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' })
];
Comment on lines +658 to +662
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can combine these 😉

Suggested change
const args = [
nativeToScVal(source, { type: 'address' }),
nativeToScVal(destination, { type: 'address' }),
nativeToScVal(amount, { type: 'i128' })
];
const args = nativeToScVal([source, destination, amount], {
type: ['address', 'address', 'i128' ]
}).vec();

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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can/should use Contract.getFootprint for this one for brevity

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
Comment on lines +766 to +774
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it OK to have 0 for these?

}),
ext: new xdr.SorobanTransactionDataExt(0),
resourceFee: new xdr.Int64(
sorobanFees ? sorobanFees.resourceFee : defaultPaymentFees.resourceFee
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

)
});
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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading