diff --git a/CHANGELOG.md b/CHANGELOG.md index 794afe90f..5126c3fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ A breaking change will get clearly marked in this log. ### Added +- Add `getTransactions` to RPC server. ([#1037](https://github.com/stellar/js-stellar-sdk/pull/1037)) - `rpc.Server` now has a `getVersionInfo` method which reports version information of the RPC instance it is connected to. ([#997](https://github.com/stellar/js-stellar-sdk/issues/997)): ```typescript diff --git a/src/rpc/api.ts b/src/rpc/api.ts index 836c2ebcb..d1e3addf4 100644 --- a/src/rpc/api.ts +++ b/src/rpc/api.ts @@ -95,6 +95,7 @@ export namespace Api { envelopeXdr: xdr.TransactionEnvelope; resultXdr: xdr.TransactionResult; resultMetaXdr: xdr.TransactionMeta; + diagnosticEventsXdr?: xdr.DiagnosticEvent[]; } export interface GetSuccessfulTransactionResponse @@ -108,6 +109,7 @@ export namespace Api { envelopeXdr: xdr.TransactionEnvelope; resultXdr: xdr.TransactionResult; resultMetaXdr: xdr.TransactionMeta; + diagnosticEventsXdr?: xdr.DiagnosticEvent[]; returnValue?: xdr.ScVal; // present iff resultMeta is a v3 } @@ -127,6 +129,56 @@ export namespace Api { resultMetaXdr?: string; ledger?: number; createdAt?: number; + diagnosticEventsXdr?: string[]; + } + + export interface GetTransactionsRequest { + startLedger: number; + cursor?: string; + limit?: number; + } + + export interface RawTransactionInfo { + status: GetTransactionStatus; + ledger: number; + createdAt: number; + applicationOrder: number; + feeBump: boolean; + envelopeXdr?: string; + resultXdr?: string; + resultMetaXdr?: string; + diagnosticEventsXdr?: string[]; + } + + export interface TransactionInfo { + status: GetTransactionStatus; + ledger: number; + createdAt: number; + applicationOrder: number; + feeBump: boolean; + envelopeXdr: xdr.TransactionEnvelope; + resultXdr: xdr.TransactionResult; + resultMetaXdr: xdr.TransactionMeta; + returnValue?: xdr.ScVal; + diagnosticEventsXdr?: xdr.DiagnosticEvent[]; + } + + export interface GetTransactionsResponse { + transactions: TransactionInfo[]; + latestLedger: number; + latestLedgerCloseTimestamp: number; + oldestLedger: number; + oldestLedgerCloseTimestamp: number; + cursor: string; + } + + export interface RawGetTransactionsResponse { + transactions: RawTransactionInfo[]; + latestLedger: number; + latestLedgerCloseTimestamp: number; + oldestLedger: number; + oldestLedgerCloseTimestamp: number; + cursor: string; } export type EventType = 'contract' | 'system' | 'diagnostic'; diff --git a/src/rpc/parsers.ts b/src/rpc/parsers.ts index 076e89e5a..ec590d719 100644 --- a/src/rpc/parsers.ts +++ b/src/rpc/parsers.ts @@ -26,6 +26,40 @@ export function parseRawSendTransaction( return { ...r } as Api.BaseSendTransactionResponse; } +export function parseTransactionInfo(raw: Api.RawTransactionInfo | Api.RawGetTransactionResponse): Omit { + const meta = xdr.TransactionMeta.fromXDR(raw.resultMetaXdr!, 'base64'); + const info: Omit = { + ledger: raw.ledger!, + createdAt: raw.createdAt!, + applicationOrder: raw.applicationOrder!, + feeBump: raw.feeBump!, + envelopeXdr: xdr.TransactionEnvelope.fromXDR(raw.envelopeXdr!, 'base64'), + resultXdr: xdr.TransactionResult.fromXDR(raw.resultXdr!, 'base64'), + resultMetaXdr: meta, + }; + + if (meta.switch() === 3 && meta.v3().sorobanMeta() !== null) { + info.returnValue = meta.v3().sorobanMeta()?.returnValue(); + } + + if ('diagnosticEventsXdr' in raw && raw.diagnosticEventsXdr) { + info.diagnosticEventsXdr = raw.diagnosticEventsXdr.map( + diagnosticEvent => xdr.DiagnosticEvent.fromXDR(diagnosticEvent, 'base64') + ); + } + + return info; +} + +export function parseRawTransactions( + r: Api.RawTransactionInfo +): Api.TransactionInfo { + return { + status: r.status, + ...parseTransactionInfo(r), + }; +} + export function parseRawEvents( r: Api.RawGetEventsResponse ): Api.GetEventsResponse { diff --git a/src/rpc/server.ts b/src/rpc/server.ts index b774fc993..b3e0d70e3 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -22,7 +22,9 @@ import { parseRawSendTransaction, parseRawSimulation, parseRawLedgerEntries, - parseRawEvents + parseRawEvents, + parseRawTransactions, + parseTransactionInfo, } from './parsers'; export const SUBMIT_TRANSACTION_TIMEOUT = 60 * 1000; @@ -439,30 +441,13 @@ export class Server { hash: string ): Promise { return this._getTransaction(hash).then((raw) => { - let foundInfo: Omit< - Api.GetSuccessfulTransactionResponse, - keyof Api.GetMissingTransactionResponse + const foundInfo: Omit< + Api.GetSuccessfulTransactionResponse, + keyof Api.GetMissingTransactionResponse > = {} as any; if (raw.status !== Api.GetTransactionStatus.NOT_FOUND) { - const meta = xdr.TransactionMeta.fromXDR(raw.resultMetaXdr!, 'base64'); - foundInfo = { - ledger: raw.ledger!, - createdAt: raw.createdAt!, - applicationOrder: raw.applicationOrder!, - feeBump: raw.feeBump!, - envelopeXdr: xdr.TransactionEnvelope.fromXDR( - raw.envelopeXdr!, - 'base64' - ), - resultXdr: xdr.TransactionResult.fromXDR(raw.resultXdr!, 'base64'), - resultMetaXdr: meta, - ...(meta.switch() === 3 && - meta.v3().sorobanMeta() !== null && - raw.status === Api.GetTransactionStatus.SUCCESS && { - returnValue: meta.v3().sorobanMeta()?.returnValue() - }) - }; + Object.assign(foundInfo, parseTransactionInfo(raw)); } const result: Api.GetTransactionResponse = { @@ -485,6 +470,43 @@ export class Server { return jsonrpc.postObject(this.serverURL.toString(), 'getTransaction', {hash}); } + /** + * Fetch transactions starting from a given start ledger or a cursor. The end ledger is the latest ledger + * in that RPC instance. + * + * @param {Api.GetTransactionsRequest} request - The request parameters. + * @returns {Promise} - A promise that resolves to the transactions response. + * + * @see https://developers.stellar.org/docs/data/rpc/api-reference/methods/getTransactions + * @example + * server.getTransactions({ + * startLedger: 10000, + * limit: 10, + * }).then((response) => { + * console.log("Transactions:", response.transactions); + * console.log("Latest Ledger:", response.latestLedger); + * console.log("Cursor:", response.cursor); + * }); + */ + public async getTransactions(request: Api.GetTransactionsRequest): Promise { + return this._getTransactions(request).then((raw: Api.RawGetTransactionsResponse) => { + const result: Api.GetTransactionsResponse = { + transactions: raw.transactions.map(parseRawTransactions), + latestLedger: raw.latestLedger, + latestLedgerCloseTimestamp: raw.latestLedgerCloseTimestamp, + oldestLedger: raw.oldestLedger, + oldestLedgerCloseTimestamp: raw.oldestLedgerCloseTimestamp, + cursor: raw.cursor, + } + return result + }); + } + + // Add this private method to the Server class + private async _getTransactions(request: Api.GetTransactionsRequest): Promise { + return jsonrpc.postObject(this.serverURL.toString(), 'getTransactions', request); + } + /** * Fetch all events that match a given set of filters. * diff --git a/test/unit/server/soroban/get_transactions_test.js b/test/unit/server/soroban/get_transactions_test.js new file mode 100644 index 000000000..2fc0c8c45 --- /dev/null +++ b/test/unit/server/soroban/get_transactions_test.js @@ -0,0 +1,174 @@ +const { + xdr, + Keypair, + Account, + TransactionBuilder, + nativeToScVal, + XdrLargeInt, +} = StellarSdk; +const { Server, AxiosClient } = StellarSdk.rpc; + +describe("Server#getTransactions", function () { + beforeEach(function () { + this.server = new Server(serverUrl); + this.axiosMock = sinon.mock(AxiosClient); + this.prepareAxios = (result) => { + this.axiosMock + .expects("post") + .withArgs(serverUrl, { + jsonrpc: "2.0", + id: 1, + method: "getTransactions", + params: { + startLedger: 1234, + limit: 10, + }, + }) + .returns(Promise.resolve({ data: { id: 1, result } })); + }; + }); + + afterEach(function () { + this.axiosMock.verify(); + this.axiosMock.restore(); + }); + + it("fetches transactions successfully", function (done) { + const rawResult = makeGetTransactionsResult(); + this.prepareAxios(rawResult); + + let expected = JSON.parse(JSON.stringify(rawResult)); + expected.transactions = expected.transactions.map((tx) => { + let parsedTx = { ...tx }; + [ + ["envelopeXdr", xdr.TransactionEnvelope], + ["resultXdr", xdr.TransactionResult], + ["resultMetaXdr", xdr.TransactionMeta], + ].forEach(([field, struct]) => { + parsedTx[field] = struct.fromXDR(tx[field], "base64"); + }); + if (tx.status === "SUCCESS") { + parsedTx.returnValue = parsedTx.resultMetaXdr + .v3() + .sorobanMeta() + .returnValue(); + } + return parsedTx; + }); + + this.server + .getTransactions({ startLedger: 1234, limit: 10 }) + .then((resp) => { + expect(Object.keys(resp)).to.eql(Object.keys(expected)); + expect(resp.transactions.length).to.equal(expected.transactions.length); + expect(resp).to.eql(expected); + expect(resp.transactions[0].returnValue).to.eql( + new XdrLargeInt("u64", 1234).toScVal(), + ); + expect(resp.transactions[1].returnValue).to.eql( + new XdrLargeInt("u64", 1235).toScVal(), + ); + done(); + }) + .catch((err) => done(err)); + }); + + it("empty transaction list", function (done) { + const result = { + transactions: [], + latestLedger: 100, + oldestLedger: 1, + oldestLedgerCloseTimestamp: 123456789, + latestLedgerCloseTimestamp: 987654321, + cursor: "123456", + }; + this.prepareAxios(result); + + this.server + .getTransactions({ startLedger: 1234, limit: 10 }) + .then((resp) => { + expect(resp).to.deep.equal(result); + expect(resp.transactions).to.be.an("array").that.is.empty; + done(); + }) + .catch((err) => done(err)); + }); + + it("handles errors", function (done) { + const errorResponse = { + code: -32600, + message: "Invalid request", + data: { + extras: { + reason: "Invalid startLedger", + }, + }, + }; + + this.axiosMock + .expects("post") + .withArgs(serverUrl, { + jsonrpc: "2.0", + id: 1, + method: "getTransactions", + params: { startLedger: -1, limit: 10 }, + }) + .returns(Promise.reject({ response: { data: errorResponse } })); + + this.server + .getTransactions({ startLedger: -1, limit: 10 }) + .then(() => { + done(new Error("Expected method to reject.")); + }) + .catch((err) => { + expect(err.response.data).to.eql(errorResponse); + done(); + }); + }); +}); + +function makeGetTransactionsResult(count = 2) { + const transactions = []; + for (let i = 0; i < count; i++) { + transactions.push(makeTxResult(1234 + i, i + 1, "SUCCESS")); + } + return { + transactions, + latestLedger: 100, + latestLedgerCloseTimestamp: 987654321, + oldestLedger: 1, + oldestLedgerCloseTimestamp: 123456789, + cursor: "123456", + }; +} + +function makeTxResult(ledger, applicationOrder, status) { + const metaV3 = new xdr.TransactionMeta( + 3, + new xdr.TransactionMetaV3({ + ext: new xdr.ExtensionPoint(0), + txChangesBefore: [], + operations: [], + txChangesAfter: [], + sorobanMeta: new xdr.SorobanTransactionMeta({ + ext: new xdr.SorobanTransactionMetaExt(0), + events: [], + diagnosticEvents: [], + returnValue: nativeToScVal(ledger), + }), + }), + ); + + return { + status: status, + ledger: ledger, + createdAt: ledger * 25 + 100, + applicationOrder: applicationOrder, + feeBump: false, + envelopeXdr: + "AAAAAgAAAAAT/LQZdYz0FcQ4Xwyg8IM17rkUx3pPCCWLu+SowQ/T+gBLB24poiQa9iwAngAAAAEAAAAAAAAAAAAAAABkwdeeAAAAAAAAAAEAAAABAAAAAC/9E8hDhnktyufVBS5tqA734Yz5XrLX2XNgBgH/YEkiAAAADQAAAAAAAAAAAAA1/gAAAAAv/RPIQ4Z5Lcrn1QUubagO9+GM+V6y19lzYAYB/2BJIgAAAAAAAAAAAAA1/gAAAAQAAAACU0lMVkVSAAAAAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAAAVNHWAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AAAACUEFMTEFESVVNAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAAAlNJTFZFUgAAAAAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAAAAAAAACwQ/T+gAAAEA+ztVEKWlqHXNnqy6FXJeHr7TltHzZE6YZm5yZfzPIfLaqpp+5cyKotVkj3d89uZCQNsKsZI48uoyERLne+VwL/2BJIgAAAEA7323gPSaezVSa7Vi0J4PqsnklDH1oHLqNBLwi5EWo5W7ohLGObRVQZ0K0+ufnm4hcm9J4Cuj64gEtpjq5j5cM", + resultXdr: + "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAANAAAAAAAAAAUAAAACZ4W6fmN63uhVqYRcHET+D2NEtJvhCIYflFh9GqtY+AwAAAACU0lMVkVSAAAAAAAAAAAAAFDutWuu6S6UPJBrotNSgfmXa27M++63OT7TYn1qjgy+AAAYW0toL2gAAAAAAAAAAAAANf4AAAACcgyAkXD5kObNTeRYciLh7R6ES/zzKp0n+cIK3Y6TjBkAAAABU0dYAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAGlGnIJrXAAAAAlNJTFZFUgAAAAAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAGFtLaC9oAAAAApmc7UgUBInrDvij8HMSridx2n1w3I8TVEn4sLr1LSpmAAAAAlBBTExBRElVTQAAAAAAAABQ7rVrrukulDyQa6LTUoH5l2tuzPvutzk+02J9ao4MvgAAIUz88EqYAAAAAVNHWAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABpRpyCa1wAAAAKYUsaaCZ233xB1p+lG7YksShJWfrjsmItbokiR3ifa0gAAAAJTSUxWRVIAAAAAAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABv52PPa5wAAAAJQQUxMQURJVU0AAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AACFM/PBKmAAAAAJnhbp+Y3re6FWphFwcRP4PY0S0m+EIhh+UWH0aq1j4DAAAAAAAAAAAAAA9pAAAAAJTSUxWRVIAAAAAAAAAAAAAUO61a67pLpQ8kGui01KB+Zdrbsz77rc5PtNifWqODL4AABv52PPa5wAAAAAv/RPIQ4Z5Lcrn1QUubagO9+GM+V6y19lzYAYB/2BJIgAAAAAAAAAAAAA9pAAAAAA=", + resultMetaXdr: metaV3.toXDR("base64"), + }; +}