Skip to content

Commit

Permalink
Use the new Soroban RPC simulation method when preparing transactions (
Browse files Browse the repository at this point in the history
  • Loading branch information
sreuland authored May 18, 2023
1 parent 678bbaa commit f558bb8
Show file tree
Hide file tree
Showing 9 changed files with 438 additions and 208 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ jobs:
# Workaround for some `yarn` nonsense, see:
# https://github.com/yarnpkg/yarn/issues/6312#issuecomment-429685210
- name: Install Dependencies
run: yarn install --network-concurrency 1
run: |
yarn cache clean
yarn install --network-concurrency 1
- name: Build
run: yarn build:prod
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

A breaking change should be clearly marked in this log.

#### Pending
updated prepare transaction for new soroban simulation results and fees [#76](https://github.com/stellar/js-soroban-client/issues/76)


#### 0.5.1

* remove params from jsonrpc post payload if empty. [#70](https://github.com/stellar/js-soroban-client/pull/70)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
"eventsource": "^2.0.2",
"lodash": "^4.17.21",
"randombytes": "^2.1.0",
"stellar-base": "v8.2.2-soroban.12",
"stellar-base": "^9.0.0-soroban.1",
"toml": "^3.0.0",
"urijs": "^1.19.1"
}
Expand Down
31 changes: 23 additions & 8 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,19 @@ export class Server {
}

/**
* Submit a trial contract invocation, then add the expected ledger footprint
* and auth into the transaction so it is ready for signing & sending.
* Submit a trial contract invocation, first run a simulation of the contract
* invocation as defined on the incoming transaction, and apply the results
* to a new copy of the transaction which is then returned. Setting the ledger
* footprint and authorization, so the resulting transaction is ready for signing & sending.
*
* The returned transaction will also have an updated fee that is the sum of fee set
* on incoming transaction with the contract resource fees estimated from simulation. It is
* adviseable to check the fee on returned transaction and validate or take appropriate
* measures for interaction with user to confirm it is acceptable.
*
* You can call the {simulateTransaction(transaction)} method directly first if you
* want to inspect estimated fees for a given transaction in detail first if that is
* of importance.
*
* @example
* const contractId = '0000000000000000000000000000000000000000000000000000000000000001';
Expand Down Expand Up @@ -419,25 +430,29 @@ export class Server {
* passphrase. If not passed, the current network passphrase will be requested
* from the server via `getNetwork`.
* @returns {Promise<Transaction | FeeBumpTransaction>} Returns a copy of the
* transaction, with the expected ledger footprint added.
* transaction, with the expected ledger footprint and authorizations added
* and the transaction fee will automatically be adjusted to the sum of
* the incoming transaction fee and the contract minimum resource fees
* discovered from the simulation,
*
*/
public async prepareTransaction(
transaction: Transaction | FeeBumpTransaction,
networkPassphrase?: string,
): Promise<Transaction | FeeBumpTransaction> {
const [{ passphrase }, { error, results }] = await Promise.all([
const [{ passphrase }, simResponse] = await Promise.all([
networkPassphrase
? Promise.resolve({ passphrase: networkPassphrase })
: this.getNetwork(),
this.simulateTransaction(transaction),
]);
if (error) {
throw error;
if (simResponse.error) {
throw simResponse.error;
}
if (!results) {
if (!simResponse.results || simResponse.results.length < 1) {
throw new Error("transaction simulation failed");
}
return assembleTransaction(transaction, passphrase, results);
return assembleTransaction(transaction, passphrase, simResponse);
}

/**
Expand Down
20 changes: 13 additions & 7 deletions src/soroban_rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,22 @@ export namespace SorobanRpc {
latestLedgerCloseTime: number;
}

export interface SimulateHostFunctionResult {
// each string is ContractAuth XDR in base64
auth: string[];
// function response as SCVal XDR in base64
xdr: string;
}

export interface SimulateTransactionResponse {
id: string;
cost: Cost;
results?: Array<{
xdr: string;
footprint: string;
auth: string[];
events: string[];
}>;
error?: jsonrpc.Error;
// this is SorobanTransactionData XDR in base64
transactionData: string;
events: string[];
minResourceFee: string;
results: SimulateHostFunctionResult[];
latestLedger: number;
cost: Cost;
}
}
103 changes: 63 additions & 40 deletions src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,56 @@ import {
TransactionBuilder,
xdr,
} from "stellar-base";
import { SorobanRpc } from "./soroban_rpc";

// TODO: Transaction is immutable, so we need to re-build it here. :(
export function assembleTransaction(
raw: Transaction | FeeBumpTransaction,
networkPassphrase: string,
simulated: Array<null | {
footprint: Buffer | string | xdr.LedgerFootprint;
auth: Array<Buffer | string | xdr.ContractAuth>;
}>,
simulation: SorobanRpc.SimulateTransactionResponse,
): Transaction {
if ("innerTransaction" in raw) {
// TODO: Handle feebump transactions
return assembleTransaction(
raw.innerTransaction,
networkPassphrase,
simulated,
simulation,
);
}

if (simulated.length !== raw.operations.length) {
if (
raw.operations.length !== 1 ||
raw.operations[0].type !== "invokeHostFunction"
) {
throw new Error(
"number of simulated operations not equal to number of transaction operations",
"unsupported operation type, must be only one InvokeHostFunctionOp in the transaction.",
);
}

const rawInvokeHostFunctionOp: any = raw.operations[0];

if (
!rawInvokeHostFunctionOp.functions ||
!simulation.results ||
rawInvokeHostFunctionOp.functions.length !== simulation.results.length
) {
throw new Error(
"preflight simulation results do not contain same count of HostFunctions that InvokeHostFunctionOp in the transaction has.",
);
}

// TODO: Figure out a cleaner way to clone this transaction.
const source = new Account(raw.source, `${parseInt(raw.sequence, 10) - 1}`);
const txn = new TransactionBuilder(source, {
fee: raw.fee,
const classicFeeNum = parseInt(raw.fee, 10) || 0;
const minResourceFeeNum = parseInt(simulation.minResourceFee, 10) || 0;
const txnBuilder = new TransactionBuilder(source, {
// automatically update the tx fee that will be set on the resulting tx
// to the sum of 'classic' fee provided from incoming tx.fee
// and minResourceFee provided by simulation.
//
// 'classic' tx fees are measured as the product of tx.fee * 'number of operations', In soroban contract tx,
// there can only be single operation in the tx, so can make simplification
// of total classic fees for the soroban transaction will be equal to incoming tx.fee + minResourceFee.
fee: (classicFeeNum + minResourceFeeNum).toString(),
memo: raw.memo,
networkPassphrase,
timebounds: raw.timeBounds,
Expand All @@ -44,35 +65,37 @@ export function assembleTransaction(
minAccountSequenceLedgerGap: raw.minAccountSequenceLedgerGap,
extraSigners: raw.extraSigners,
});
for (let i = 0; i < raw.operations.length; i++) {
const rawOp = raw.operations[i];
if ("function" in rawOp) {
const sim = simulated[i];
if (!sim) {
throw new Error("missing simulated operation");
}
let footprint = sim.footprint ?? rawOp.footprint;
if (!(footprint instanceof xdr.LedgerFootprint)) {
footprint = xdr.LedgerFootprint.fromXDR(footprint.toString(), "base64");
}
const auth = (sim.auth ?? rawOp.auth).map((a) =>
a instanceof xdr.ContractAuth
? a
: xdr.ContractAuth.fromXDR(a.toString(), "base64"),
);
// TODO: Figure out a cleaner way to clone these operations
txn.addOperation(
Operation.invokeHostFunction({
function: rawOp.function,
parameters: rawOp.parameters,
footprint,
auth,
}),
);
} else {
// TODO: Handle this.
throw new Error("Unsupported operation type");
}

// apply the pre-built Auth from simulation onto each Tx/Op/HostFunction
// invocation
const authDecoratedHostFunctions = simulation.results.map(
(functionSimulationResult, i) => {
const hostFn: xdr.HostFunction = rawInvokeHostFunctionOp.functions[i];
hostFn.auth(buildContractAuth(functionSimulationResult.auth));
return hostFn;
},
);

txnBuilder.addOperation(
Operation.invokeHostFunctions({
functions: authDecoratedHostFunctions,
}),
);

// apply the pre-built Soroban Tx Data from simulation onto the Tx
const sorobanTxData = xdr.SorobanTransactionData.fromXDR(
simulation.transactionData,
"base64",
);
txnBuilder.setSorobanData(sorobanTxData);

return txnBuilder.build();
}

function buildContractAuth(auths: string[]): xdr.ContractAuth[] {
const contractAuths: xdr.ContractAuth[] = [];
for (const authStr of auths) {
contractAuths.push(xdr.ContractAuth.fromXDR(authStr, "base64"));
}
return txn.build();
return contractAuths;
}
111 changes: 73 additions & 38 deletions test/unit/server/simulate_transaction_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,75 @@ describe("Server#simulateTransaction", function () {
"56199647068161"
);

beforeEach(function () {
const simulationResponse = {
transactionData: new SorobanClient.xdr.SorobanTransactionData({
resources: new SorobanClient.xdr.SorobanResources({
footprint: new SorobanClient.xdr.LedgerFootprint({
readOnly: [],
readWrite: [],
}),
instructions: 0,
readBytes: 0,
writeBytes: 0,
extendedMetaDataSizeBytes: 0,
}),
refundableFee: SorobanClient.xdr.Int64.fromString("0"),
ext: new SorobanClient.xdr.ExtensionPoint(0),
}).toXDR("base64"),
events: [],
minResourceFee: "15",
results: [
{
auth: [
new SorobanClient.xdr.ContractAuth({
addressWithNonce: null,
rootInvocation: new SorobanClient.xdr.AuthorizedInvocation({
contractId: Buffer.alloc(32),
functionName: "fn",
args: [],
subInvocations: [],
}),
signatureArgs: [],
}).toXDR("base64"),
],
xdr: SorobanClient.xdr.ScVal.scvU32(0)
.toXDR()
.toString("base64"),
},
],
latestLedger: 3,
cost: {
cpuInsns: "0",
memBytes: "0",
},
};

beforeEach(function() {
this.server = new SorobanClient.Server(serverUrl);
this.axiosMock = sinon.mock(AxiosClient);
let transaction = new SorobanClient.TransactionBuilder(account, {
fee: 100,
networkPassphrase: SorobanClient.Networks.TESTNET,
v1: true,
})
.addOperation(
SorobanClient.Operation.payment({
destination:
"GASOCNHNNLYFNMDJYQ3XFMI7BYHIOCFW3GJEOWRPEGK2TDPGTG2E5EDW",
asset: SorobanClient.Asset.native(),
amount: "100.50",
})
)
.setTimeout(SorobanClient.TimeoutInfinite)
.build();
const source = new SorobanClient.Account(
"GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI",
"1",
);
function emptyContractTransaction() {
return new SorobanClient.TransactionBuilder(source, {
fee: 100,
networkPassphrase: "Test",
v1: true,
})
.addOperation(
SorobanClient.Operation.invokeHostFunction({
args: new SorobanClient.xdr.HostFunctionArgs.hostFunctionTypeInvokeContract(
[],
),
auth: [],
}),
)
.setTimeout(SorobanClient.TimeoutInfinite)
.build();
}

const transaction = emptyContractTransaction();
transaction.sign(keypair);

this.transaction = transaction;
Expand All @@ -35,25 +86,7 @@ describe("Server#simulateTransaction", function () {
this.axiosMock.restore();
});

const result = {
cost: {
cpuInsns: "10000",
memBytes: "10000",
},
results: [
{
xdr: SorobanClient.xdr.ScVal.scvU32(0).toXDR().toString("base64"),
footprint: new SorobanClient.xdr.LedgerFootprint({
readOnly: [],
readWrite: [],
}).toXDR("base64"),
events: [],
},
],
latestLedger: 1,
};

it("simulates a transaction", function (done) {
it("simulates a transaction", function(done) {
this.axiosMock
.expects("post")
.withArgs(serverUrl, {
Expand All @@ -62,12 +95,14 @@ describe("Server#simulateTransaction", function () {
method: "simulateTransaction",
params: [this.blob],
})
.returns(Promise.resolve({ data: { id: 1, result } }));
.returns(
Promise.resolve({ data: { id: 1, result: simulationResponse } }),
);

this.server
.simulateTransaction(this.transaction)
.then(function (response) {
expect(response).to.be.deep.equal(result);
.then(function(response) {
expect(response).to.be.deep.equal(simulationResponse);
done();
})
.catch(function (err) {
Expand Down
Loading

0 comments on commit f558bb8

Please sign in to comment.