Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RPC: Add support for getTransactions endpoint #1037

Merged
merged 12 commits into from
Sep 13, 2024
49 changes: 49 additions & 0 deletions src/rpc/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,55 @@ export namespace Api {
createdAt?: number;
}

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';

export interface EventFilter {
Expand Down
34 changes: 34 additions & 0 deletions src/rpc/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,40 @@ export function parseRawSendTransaction(
return { ...r } as Api.BaseSendTransactionResponse;
}

export function parseTransactionInfo(raw: Api.RawTransactionInfo | Api.RawGetTransactionResponse): Omit<Api.TransactionInfo, 'status'> {
const meta = xdr.TransactionMeta.fromXDR(raw.resultMetaXdr!, 'base64');
const info: Omit<Api.TransactionInfo, 'status'> = {
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 {
Expand Down
66 changes: 44 additions & 22 deletions src/rpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
parseRawSendTransaction,
parseRawSimulation,
parseRawLedgerEntries,
parseRawEvents
parseRawEvents,
parseRawTransactions,
parseTransactionInfo,
} from './parsers';

export const SUBMIT_TRANSACTION_TIMEOUT = 60 * 1000;
Expand Down Expand Up @@ -439,30 +441,13 @@ export class Server {
hash: string
): Promise<Api.GetTransactionResponse> {
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 = {
Expand All @@ -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<Api.GetTransactionsResponse>} - 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<Api.GetTransactionsResponse> {
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,
}
aditya1702 marked this conversation as resolved.
Show resolved Hide resolved
return result
});
}

// Add this private method to the Server class
private async _getTransactions(request: Api.GetTransactionsRequest): Promise<Api.RawGetTransactionsResponse> {
return jsonrpc.postObject(this.serverURL.toString(), 'getTransactions', request);
}

/**
* Fetch all events that match a given set of filters.
*
Expand Down
174 changes: 174 additions & 0 deletions test/unit/server/soroban/get_transactions_test.js
Original file line number Diff line number Diff line change
@@ -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"),
};
}
Loading