diff --git a/explorer/package-lock.json b/explorer/package-lock.json index aaace1748..f0c00cf83 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -33,7 +33,7 @@ "eslint-plugin-svelte": "2.35.1", "jsdom": "24.0.0", "jsdom-worker": "0.3.0", - "lamb-types": "0.61.1", + "lamb-types": "0.61.3", "postcss-nested": "6.0.1", "prettier": "3.2.5", "prettier-plugin-svelte": "3.2.2", @@ -50,6 +50,11 @@ "node": ">=20.0.0" } }, + "../../../lamb-types": { + "version": "0.61.3", + "extraneous": true, + "license": "MIT" + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -5121,10 +5126,11 @@ } }, "node_modules/lamb-types": { - "version": "0.61.1", - "resolved": "https://registry.npmjs.org/lamb-types/-/lamb-types-0.61.1.tgz", - "integrity": "sha512-KkyvhJ60EadNvb/oXKV57TguRpWsvs4Qcw1L10v+7JHsOePHdrRlEiCkCoU8NGocRP/HK4Aa3VxUxGsxlQYwvQ==", - "dev": true + "version": "0.61.3", + "resolved": "https://registry.npmjs.org/lamb-types/-/lamb-types-0.61.3.tgz", + "integrity": "sha512-swVg/9PV5txaslZObWGgZXzgqvENXmqaM0vRzVSNFxeZrxk9B+0W7hocDPzFKLgqFF1U3jhZ0HjO8glalgEm5A==", + "dev": true, + "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", diff --git a/explorer/package.json b/explorer/package.json index c306d88da..29a6ced49 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -59,7 +59,7 @@ "eslint-plugin-svelte": "2.35.1", "jsdom": "24.0.0", "jsdom-worker": "0.3.0", - "lamb-types": "0.61.1", + "lamb-types": "0.61.3", "postcss-nested": "6.0.1", "prettier": "3.2.5", "prettier-plugin-svelte": "3.2.2", diff --git a/explorer/src/lib/chain-info/__tests__/transformAPITransaction.spec.js b/explorer/src/lib/chain-info/__tests__/transformAPITransaction.spec.js new file mode 100644 index 000000000..65b0bf6da --- /dev/null +++ b/explorer/src/lib/chain-info/__tests__/transformAPITransaction.spec.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { skipIn } from "lamb"; + +import { apiTransaction } from "$lib/mock-data"; + +import { transformAPITransaction } from ".."; + +describe("transformAPITransaction", () => { + const txData = apiTransaction.data[0]; + const expectedTx = { + blockhash: + "3c6e4018cfa86723e50644e33d3990bc27fc794f6b49fbf6290e4d308e07bd2d", + blockheight: 487166, + contract: "Transfer", + date: new Date(txData.blockts * 1000), + feepaid: 290766, + gaslimit: 500000000, + gasprice: 1, + gasspent: 290766, + method: "transfer", + success: true, + txerror: "", + txid: "4877687c2dbf154248d3ddee9ba0d81e3431f39056f82a46819da041d4ac0e04", + }; + + it("should transform a transaction received from the API into the format used by the Explorer", () => { + expect(transformAPITransaction(txData)).toStrictEqual(expectedTx); + }); + + it("should give defaults to optional properties if they are missing", () => { + const incompleteTx = skipIn(txData, ["method"]); + + expect(transformAPITransaction(incompleteTx)).toStrictEqual({ + ...expectedTx, + method: "", + }); + }); +}); diff --git a/explorer/src/lib/chain-info/__tests__/transformTransaction.spec.js b/explorer/src/lib/chain-info/__tests__/transformTransaction.spec.js index 69b0aa6f7..2955245b2 100644 --- a/explorer/src/lib/chain-info/__tests__/transformTransaction.spec.js +++ b/explorer/src/lib/chain-info/__tests__/transformTransaction.spec.js @@ -1,18 +1,17 @@ import { describe, expect, it } from "vitest"; -import { skipIn } from "lamb"; -import { apiTransaction } from "$lib/mock-data"; +import { gqlTransaction } from "$lib/mock-data"; import { transformTransaction } from ".."; describe("transformTransaction", () => { - const txData = apiTransaction.data[0]; + const txData = gqlTransaction.tx; const expectedTx = { blockhash: "3c6e4018cfa86723e50644e33d3990bc27fc794f6b49fbf6290e4d308e07bd2d", blockheight: 487166, contract: "Transfer", - date: new Date(txData.blockts * 1000), + date: new Date(txData.blockTimestamp * 1000), feepaid: 290766, gaslimit: 500000000, gasprice: 1, @@ -23,16 +22,42 @@ describe("transformTransaction", () => { txid: "4877687c2dbf154248d3ddee9ba0d81e3431f39056f82a46819da041d4ac0e04", }; - it("should transform a block received from the API into the format used by the Explorer", () => { + it("should transform a transaction received from the GraphQL API into the format used by the Explorer", () => { expect(transformTransaction(txData)).toStrictEqual(expectedTx); }); - it("should give defaults to optional properties if they are missing", () => { - const incompleteTx = skipIn(txData, ["method"]); + it("should use the call data if present to set the method and contract name", () => { + const data = { + ...txData, + tx: { + ...txData.tx, + callData: { + contractId: + "0200000000000000000000000000000000000000000000000000000000000000", + fnName: "stake", + }, + }, + }; + const expected = { + ...expectedTx, + contract: "Stake", + method: "stake", + }; + + expect(transformTransaction(data)).toStrictEqual(expected); + }); - expect(transformTransaction(incompleteTx)).toStrictEqual({ + it("should set the success property to `false` if the an error is present and use the message in the `txerror` property", () => { + const data = { + ...txData, + err: "Some error message", + }; + const expected = { ...expectedTx, - method: "", - }); + success: false, + txerror: data.err, + }; + + expect(transformTransaction(data)).toStrictEqual(expected); }); }); diff --git a/explorer/src/lib/chain-info/index.js b/explorer/src/lib/chain-info/index.js index e0c8395b1..e9b659c9a 100644 --- a/explorer/src/lib/chain-info/index.js +++ b/explorer/src/lib/chain-info/index.js @@ -1,3 +1,4 @@ export { default as transformBlock } from "./transformBlock"; export { default as transformSearchResult } from "./transformSearchResult"; +export { default as transformAPITransaction } from "./transformAPITransaction"; export { default as transformTransaction } from "./transformTransaction"; diff --git a/explorer/src/lib/chain-info/transformAPITransaction.js b/explorer/src/lib/chain-info/transformAPITransaction.js new file mode 100644 index 000000000..d2d3bcb84 --- /dev/null +++ b/explorer/src/lib/chain-info/transformAPITransaction.js @@ -0,0 +1,12 @@ +import { skipIn } from "lamb"; + +import { unixTsToDate } from "$lib/dusk/date"; + +/** @type {(v: APITransaction) => Transaction} */ +const transformTransaction = (v) => ({ + ...skipIn(v, ["__typename", "blocktimestamp", "blockts", "txtype"]), + date: unixTsToDate(v.blockts), + method: v.method ?? "", +}); + +export default transformTransaction; diff --git a/explorer/src/lib/chain-info/transformBlock.js b/explorer/src/lib/chain-info/transformBlock.js index ceb8f5748..651d58b63 100644 --- a/explorer/src/lib/chain-info/transformBlock.js +++ b/explorer/src/lib/chain-info/transformBlock.js @@ -2,7 +2,7 @@ import { mapWith, pipe, skip, updateIn } from "lamb"; import { unixTsToDate } from "$lib/dusk/date"; -import { transformTransaction } from "."; +import { transformAPITransaction } from "."; /** @type {(v: APIBlockHeader) => Required} */ const mergeWithDefaults = (v) => ({ @@ -28,7 +28,11 @@ const transformBlockHeader = pipe([ /** @type {(v: APIBlock) => Block} */ const transformBlock = ({ header, transactions }) => ({ header: transformBlockHeader(header), - transactions: updateIn(transactions, "data", mapWith(transformTransaction)), + transactions: updateIn( + transactions, + "data", + mapWith(transformAPITransaction) + ), }); export default transformBlock; diff --git a/explorer/src/lib/chain-info/transformTransaction.js b/explorer/src/lib/chain-info/transformTransaction.js index d2d3bcb84..cf526356a 100644 --- a/explorer/src/lib/chain-info/transformTransaction.js +++ b/explorer/src/lib/chain-info/transformTransaction.js @@ -1,12 +1,22 @@ -import { skipIn } from "lamb"; - import { unixTsToDate } from "$lib/dusk/date"; -/** @type {(v: APITransaction) => Transaction} */ -const transformTransaction = (v) => ({ - ...skipIn(v, ["__typename", "blocktimestamp", "blockts", "txtype"]), - date: unixTsToDate(v.blockts), - method: v.method ?? "", +/** @param {string} [s] */ +const capitalize = (s) => (s ? `${s[0].toUpperCase()}${s.slice(1)}` : ""); + +/** @type {(v: GQLTransaction) => Transaction} */ +const transformTransaction = (tx) => ({ + blockhash: tx.blockHash, + blockheight: tx.blockHeight, + contract: tx.tx.callData ? capitalize(tx.tx.callData.fnName) : "Transfer", + date: unixTsToDate(tx.blockTimestamp), + feepaid: tx.gasSpent, + gaslimit: tx.tx.gasLimit, + gasprice: tx.tx.gasPrice, + gasspent: tx.gasSpent, + method: tx.tx.callData?.fnName ?? "transfer", + success: tx.err === null, + txerror: tx.err ?? "", + txid: tx.id, }); export default transformTransaction; diff --git a/explorer/src/lib/components/__tests__/TransactionDetails.spec.js b/explorer/src/lib/components/__tests__/TransactionDetails.spec.js index 652f11076..3623ab393 100644 --- a/explorer/src/lib/components/__tests__/TransactionDetails.spec.js +++ b/explorer/src/lib/components/__tests__/TransactionDetails.spec.js @@ -1,7 +1,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render } from "@testing-library/svelte"; import { apiMarketData, apiTransaction } from "$lib/mock-data"; -import { transformTransaction } from "$lib/chain-info"; +import { transformAPITransaction } from "$lib/chain-info"; import { TransactionDetails } from "../"; global.ResizeObserver = vi.fn().mockImplementation(() => ({ @@ -11,7 +11,7 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({ })); const baseProps = { - data: transformTransaction(apiTransaction.data[0]), + data: transformAPITransaction(apiTransaction.data[0]), error: null, loading: false, market: { diff --git a/explorer/src/lib/components/__tests__/TransactionsCard.spec.js b/explorer/src/lib/components/__tests__/TransactionsCard.spec.js index 9a5169886..3fde56fa2 100644 --- a/explorer/src/lib/components/__tests__/TransactionsCard.spec.js +++ b/explorer/src/lib/components/__tests__/TransactionsCard.spec.js @@ -1,7 +1,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/svelte"; import { apiTransactions } from "$lib/mock-data"; -import { transformTransaction } from "$lib/chain-info"; +import { transformAPITransaction } from "$lib/chain-info"; import { TransactionsCard } from ".."; import { compose, mapWith, take } from "lamb"; @@ -11,7 +11,7 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({ unobserve: vi.fn(), })); -const getTenTransactions = compose(mapWith(transformTransaction), take(10)); +const getTenTransactions = compose(mapWith(transformAPITransaction), take(10)); const data = getTenTransactions(apiTransactions.data); describe("Transactions Card", () => { diff --git a/explorer/src/lib/components/__tests__/TransactionsList.spec.js b/explorer/src/lib/components/__tests__/TransactionsList.spec.js index b341f5d11..895610dee 100644 --- a/explorer/src/lib/components/__tests__/TransactionsList.spec.js +++ b/explorer/src/lib/components/__tests__/TransactionsList.spec.js @@ -2,7 +2,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/svelte"; import { TransactionsList } from ".."; import { apiTransaction } from "$lib/mock-data"; -import { transformTransaction } from "$lib/chain-info"; +import { transformAPITransaction } from "$lib/chain-info"; global.ResizeObserver = vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), @@ -10,7 +10,7 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({ unobserve: vi.fn(), })); -const baseProps = { data: transformTransaction(apiTransaction.data[0]) }; +const baseProps = { data: transformAPITransaction(apiTransaction.data[0]) }; describe("Transactions List", () => { vi.useFakeTimers(); diff --git a/explorer/src/lib/components/__tests__/TransactionsTable.spec.js b/explorer/src/lib/components/__tests__/TransactionsTable.spec.js index b293f61af..8b5866277 100644 --- a/explorer/src/lib/components/__tests__/TransactionsTable.spec.js +++ b/explorer/src/lib/components/__tests__/TransactionsTable.spec.js @@ -1,12 +1,12 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/svelte"; import { apiTransactions } from "$lib/mock-data"; -import { transformTransaction } from "$lib/chain-info"; +import { transformAPITransaction } from "$lib/chain-info"; import { TransactionsTable } from ".."; import { mapWith, slice } from "lamb"; -const transformTransactions = mapWith(transformTransaction); -const data = slice(transformTransactions(apiTransactions.data), 0, 10); +const transformAPITransactions = mapWith(transformAPITransaction); +const data = slice(transformAPITransactions(apiTransactions.data), 0, 10); describe("Transactions Table", () => { vi.useFakeTimers(); diff --git a/explorer/src/lib/mock-data/api-transaction-details.json b/explorer/src/lib/mock-data/api-transaction-details.json deleted file mode 100644 index 97e0e5b73..000000000 --- a/explorer/src/lib/mock-data/api-transaction-details.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "data": { - "__typename": "SpentTransaction", - "json": "\"895eaca82329f2790cd088d0455df256e6ad19532ae3847ee96d57d0ff48d45702000000000000007d9800677306c6292f1b1aa0813b7e610eb5ced648511dca02e38902c4cf7745fe13a80b5610ae3c56fbb2b0b52674238372d757ee6984a12eda409c55322055020000000000000001d4e31989d13fe1001088a4d314dcc0ae6deb76d4ce020124ba286cbc6dcc134898ab826f21e6d34060f380d2575b6525d8891e1fe7b608f1cb5009160fa7ce52febd32cb33fe8d9ceb39ac3703042eaf63b3e808ad9c3da73dcaa88161af87c811f81a85e34270bd9f14007b11d12c25d27d27c3b89aec08f0f0b39e7f0d4148ffffffffffffffff50763efe82a7afeea58be5b2ea895632da898822041ebae4bf475a636832831a94bafc491a03d5fe99d6ee1baa27d4494d9e188502a89d2057371f7f083dc14d312e5187c07f35a29dedee3b5ba3bfeccd8c0a354a72b9976f965751767a0d41017615f1bf56a6d5b5bd12d7c123fe44ed13b8cb9e21ef0c8e415affebc362809dbddc27284040cce91618ad6c8be5480d5670fe73f82dd726487c7fc5b30b6f5bde80809d0ee059bad9749c385b78f3b7caa6f19e34f67b5817c709babf443adbd8d2ed9470b6b24866014d9ebdb3f11e42ce582e980f1128e66a1ee98eb94b1affffffffffffffff5039b6313a283df3735bb7ca4596fd63610757f184ab2f981a9ec079642087093550be9e079fe250581a88be65c359959a0d6c927f4379c70f3354491f839a6e34db0bb14e74992d5a65f4907f5ae736c94a95fff9641be912f74aa84f3cce430065cd1d0000000001000000000000006a6ff1c231587e85f757e5a56d5cc1507b5be7a88e3eee15d4f380eb470c5db79804dffa4f1924f6a3dee48580ba3ce5081fe2439ca304c5a8a6b0d4e5d8f3bb0010040000000000008d8e5bf383953287e016dd88ef26f2518752ea38ff981bc3b89cc6a4f9d548a59f495ed9aef0f39f20038d2105732302867d63c088ea378c115fadec7ba7a6e6c71adfbeac2a000083d3388ce96af7fcd2d740ec7009e993bf8f4ca096a766e88f476b68ab9a22dc9a1109714c431d603ce20a6b055a708909d562cd14f8111008c854e0d4647df9c619c8148b2c5e04a8b26b71d8bfb2dd97cce7c1ead4e5bacfee82fb9e6d994e69b7197b84db0b135977d29ec1810faaff215fd900aa3c3991ff256bea95bd8e9df7b7396cadc5330e59497cf9a19fdb8ac960b45c2114364bdfc6f77fb9e814cef9ec38cd5bfc76946f4eb45e8505a355e6da59f02729ee0b3bc6783c079bbc44eae47f9fcf506607f729f89f03957a60dfb9e89fe090398dee5c629e2be91bcbff34d6bd9eda0b4ad9a6d8a8df2e073645c9d20f790ebdf631384a9d9e5bafcb86a5e260f498dea1d73bd485ce3f13cb7a4c4f051045c8ae9e85a7c3b5143fb48937299bead284e27eed89cc027064a8077570335c6f508472da59f8295504e105466ce943e5cf094dfce4f7b38b0cfe374e5ee3c085dfbd4899245085ecd1e00972b9e4ac478184897fda061a9f166ac3687dbb2a5a81beab965cfaa8840dd968c6982f434f1619446d3735184f24a1f20eead80f3b839519f5168c5c790f077bf82182e7a6931a856fe3aac69c5544470f20ff82be0a8624c98605b28e33bd7247f2dd3dc02fbe77b3ec1e1e28a70dcea7ad357cdcb4fa050d02067ddc78d74dc60a1eff084adf1f5bd3db6f57f279d6f14314c5f3645b0ac0a9892d8cd08c557cf9096967054bfe5dae3704f944ea5ad8fba691d5a7d832206edb087adba59870343d86a74da8356da37e8ce035b9a2ee1d13614eb1b8f9e1559f1768c24cb4500587cc456485df3c35338a68c2c2210c8c5f20be835ebf9309b1c38af7a1c6d4727508d318eaaef8c61346b1e80473431d05c4d3fa837f6e1f9b0ec85c57ee71740e7ce4059e105735363f4e1788fffb83e332d4fe70e17b27d7bce01cc322cdd2a01c813215abe6bddb2ee2dd6b9aca69fb1e40f46312845948750d6bfa9a834fb0b1ad41a4bfdaf127c472182a02e6c2ef83f915f01caae6ecb9fcc3fd4b7565db84cc6867609b37ca54510b2e65b0e8637b8aeb81d585ccfefb7458ffeaf7b65145555ac4065468ffe73d8a2d2179ec808a4a83a49ea5b9255dafa6d276d8c564df336cbfe831bfd6094055f22bb189e33581ed762dcfc87df5d2dd60d6b6736bb5be3dd295b912e75bee854981f393fc70e64a8fa6b114a00f3befb6399dcad0c3743f4e8e406e931515ac50582ee3e5cb47f1da6b3fbfe9421eb28d33088ed3683322252224ad81178b7d77180aab9f1045a0aa684a50e85ec8a1257d4a880e39b765661e69ff1060af8fce2259438c9b0cffb908ab4148bd03c1281abe8b84f4572b00\"" - } -} diff --git a/explorer/src/lib/mock-data/gql-chain-info.d.ts b/explorer/src/lib/mock-data/gql-chain-info.d.ts new file mode 100644 index 000000000..57063a02a --- /dev/null +++ b/explorer/src/lib/mock-data/gql-chain-info.d.ts @@ -0,0 +1,19 @@ +type GQLCallData = { + contractId: string; + fnName: string; +}; + +type GQLTransaction = { + blockHash: string; + blockHeight: number; + blockTimestamp: number; + err: string | null; + gasSpent: number; + id: string; + tx: { + callData: GQLCallData | null; + gasLimit: number; + gasPrice: number; + id: string; + }; +}; diff --git a/explorer/src/lib/mock-data/gql-transaction-details.json b/explorer/src/lib/mock-data/gql-transaction-details.json new file mode 100644 index 000000000..7a7cf7e53 --- /dev/null +++ b/explorer/src/lib/mock-data/gql-transaction-details.json @@ -0,0 +1,5 @@ +{ + "tx": { + "raw": "\"895eaca82329f2790cd088d0455df256e6ad19532ae3847ee96d57d0ff48d45702000000000000007d9800677306c6292f1b1aa0813b7e610eb5ced648511dca02e38902c4cf7745fe13a80b5610ae3c56fbb2b0b52674238372d757ee6984a12eda409c55322055020000000000000001d4e31989d13fe1001088a4d314dcc0ae6deb76d4ce020124ba286cbc6dcc134898ab826f21e6d34060f380d2575b6525d8891e1fe7b608f1cb5009160fa7ce52febd32cb33fe8d9ceb39ac3703042eaf63b3e808ad9c3da73dcaa88161af87c811f81a85e34270bd9f14007b11d12c25d27d27c3b89aec08f0f0b39e7f0d4148ffffffffffffffff50763efe82a7afeea58be5b2ea895632da898822041ebae4bf475a636832831a94bafc491a03d5fe99d6ee1baa27d4494d9e188502a89d2057371f7f083dc14d312e5187c07f35a29dedee3b5ba3bfeccd8c0a354a72b9976f965751767a0d41017615f1bf56a6d5b5bd12d7c123fe44ed13b8cb9e21ef0c8e415affebc362809dbddc27284040cce91618ad6c8be5480d5670fe73f82dd726487c7fc5b30b6f5bde80809d0ee059bad9749c385b78f3b7caa6f19e34f67b5817c709babf443adbd8d2ed9470b6b24866014d9ebdb3f11e42ce582e980f1128e66a1ee98eb94b1affffffffffffffff5039b6313a283df3735bb7ca4596fd63610757f184ab2f981a9ec079642087093550be9e079fe250581a88be65c359959a0d6c927f4379c70f3354491f839a6e34db0bb14e74992d5a65f4907f5ae736c94a95fff9641be912f74aa84f3cce430065cd1d0000000001000000000000006a6ff1c231587e85f757e5a56d5cc1507b5be7a88e3eee15d4f380eb470c5db79804dffa4f1924f6a3dee48580ba3ce5081fe2439ca304c5a8a6b0d4e5d8f3bb0010040000000000008d8e5bf383953287e016dd88ef26f2518752ea38ff981bc3b89cc6a4f9d548a59f495ed9aef0f39f20038d2105732302867d63c088ea378c115fadec7ba7a6e6c71adfbeac2a000083d3388ce96af7fcd2d740ec7009e993bf8f4ca096a766e88f476b68ab9a22dc9a1109714c431d603ce20a6b055a708909d562cd14f8111008c854e0d4647df9c619c8148b2c5e04a8b26b71d8bfb2dd97cce7c1ead4e5bacfee82fb9e6d994e69b7197b84db0b135977d29ec1810faaff215fd900aa3c3991ff256bea95bd8e9df7b7396cadc5330e59497cf9a19fdb8ac960b45c2114364bdfc6f77fb9e814cef9ec38cd5bfc76946f4eb45e8505a355e6da59f02729ee0b3bc6783c079bbc44eae47f9fcf506607f729f89f03957a60dfb9e89fe090398dee5c629e2be91bcbff34d6bd9eda0b4ad9a6d8a8df2e073645c9d20f790ebdf631384a9d9e5bafcb86a5e260f498dea1d73bd485ce3f13cb7a4c4f051045c8ae9e85a7c3b5143fb48937299bead284e27eed89cc027064a8077570335c6f508472da59f8295504e105466ce943e5cf094dfce4f7b38b0cfe374e5ee3c085dfbd4899245085ecd1e00972b9e4ac478184897fda061a9f166ac3687dbb2a5a81beab965cfaa8840dd968c6982f434f1619446d3735184f24a1f20eead80f3b839519f5168c5c790f077bf82182e7a6931a856fe3aac69c5544470f20ff82be0a8624c98605b28e33bd7247f2dd3dc02fbe77b3ec1e1e28a70dcea7ad357cdcb4fa050d02067ddc78d74dc60a1eff084adf1f5bd3db6f57f279d6f14314c5f3645b0ac0a9892d8cd08c557cf9096967054bfe5dae3704f944ea5ad8fba691d5a7d832206edb087adba59870343d86a74da8356da37e8ce035b9a2ee1d13614eb1b8f9e1559f1768c24cb4500587cc456485df3c35338a68c2c2210c8c5f20be835ebf9309b1c38af7a1c6d4727508d318eaaef8c61346b1e80473431d05c4d3fa837f6e1f9b0ec85c57ee71740e7ce4059e105735363f4e1788fffb83e332d4fe70e17b27d7bce01cc322cdd2a01c813215abe6bddb2ee2dd6b9aca69fb1e40f46312845948750d6bfa9a834fb0b1ad41a4bfdaf127c472182a02e6c2ef83f915f01caae6ecb9fcc3fd4b7565db84cc6867609b37ca54510b2e65b0e8637b8aeb81d585ccfefb7458ffeaf7b65145555ac4065468ffe73d8a2d2179ec808a4a83a49ea5b9255dafa6d276d8c564df336cbfe831bfd6094055f22bb189e33581ed762dcfc87df5d2dd60d6b6736bb5be3dd295b912e75bee854981f393fc70e64a8fa6b114a00f3befb6399dcad0c3743f4e8e406e931515ac50582ee3e5cb47f1da6b3fbfe9421eb28d33088ed3683322252224ad81178b7d77180aab9f1045a0aa684a50e85ec8a1257d4a880e39b765661e69ff1060af8fce2259438c9b0cffb908ab4148bd03c1281abe8b84f4572b00\"" + } +} diff --git a/explorer/src/lib/mock-data/gql-transaction.json b/explorer/src/lib/mock-data/gql-transaction.json new file mode 100644 index 000000000..bfd57a3be --- /dev/null +++ b/explorer/src/lib/mock-data/gql-transaction.json @@ -0,0 +1,16 @@ +{ + "tx": { + "blockHash": "3c6e4018cfa86723e50644e33d3990bc27fc794f6b49fbf6290e4d308e07bd2d", + "blockHeight": 487166, + "blockTimestamp": 1713249549, + "err": null, + "gasSpent": 290766, + "id": "4877687c2dbf154248d3ddee9ba0d81e3431f39056f82a46819da041d4ac0e04", + "tx": { + "callData": null, + "gasLimit": 500000000, + "gasPrice": 1, + "id": "4877687c2dbf154248d3ddee9ba0d81e3431f39056f82a46819da041d4ac0e04" + } + } +} diff --git a/explorer/src/lib/mock-data/index.js b/explorer/src/lib/mock-data/index.js index 1c04177c7..954ba89cf 100644 --- a/explorer/src/lib/mock-data/index.js +++ b/explorer/src/lib/mock-data/index.js @@ -8,5 +8,6 @@ export { default as apiSearchNoResult } from "./api-search-no-result.json"; export { default as apiSearchTransactionResult } from "./api-search-transaction-result.json"; export { default as apiStats } from "./api-stats.json"; export { default as apiTransaction } from "./api-transaction.json"; -export { default as apiTransactionDetails } from "./api-transaction-details.json"; export { default as apiTransactions } from "./api-transactions.json"; +export { default as gqlTransaction } from "./gql-transaction.json"; +export { default as gqlTransactionDetails } from "./gql-transaction-details.json"; diff --git a/explorer/src/lib/services/__tests__/duskAPI.spec.js b/explorer/src/lib/services/__tests__/duskAPI.spec.js index 9b0a043f8..f7928126f 100644 --- a/explorer/src/lib/services/__tests__/duskAPI.spec.js +++ b/explorer/src/lib/services/__tests__/duskAPI.spec.js @@ -3,6 +3,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import * as mockData from "$lib/mock-data"; import { + transformAPITransaction, transformBlock, transformSearchResult, transformTransaction, @@ -22,10 +23,13 @@ describe("duskAPI", () => { method: "GET", }; + /** @type {string} */ + const gqlExpectedURL = `https://${node}/02/Chain`; + const endpointEnvName = "VITE_API_ENDPOINT"; /** @type {(endpoint: string) => URL} */ - const getExpectedURL = (endpoint) => + const getAPIExpectedURL = (endpoint) => new URL(`${import.meta.env[endpointEnvName]}/${endpoint}?node=${node}`); /** @type {(data: Record) => Response} */ @@ -40,59 +44,59 @@ describe("duskAPI", () => { fetchSpy.mockRestore(); }); - it("should expose a method to retrieve a single block", () => { + it("should expose a method to retrieve a single block", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiBlock)); - expect(duskAPI.getBlock(node, fakeID)).resolves.toStrictEqual( + await expect(duskAPI.getBlock(node, fakeID)).resolves.toStrictEqual( transformBlock(mockData.apiBlock.data.blocks[0]) ); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL(`blocks/${fakeID}`), + getAPIExpectedURL(`blocks/${fakeID}`), apiGetOptions ); }); - it("should expose a method to retrieve the list of blocks", () => { + it("should expose a method to retrieve the list of blocks", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiBlocks)); - expect(duskAPI.getBlocks(node)).resolves.toStrictEqual( + await expect(duskAPI.getBlocks(node)).resolves.toStrictEqual( mockData.apiBlocks.data.blocks.map(transformBlock) ); - expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL("blocks"), + getAPIExpectedURL("blocks"), apiGetOptions ); }); - it("should expose a method to retrieve the latest chain info", () => { + it("should expose a method to retrieve the latest chain info", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiLatestChainInfo)); - expect(duskAPI.getLatestChainInfo(node)).resolves.toStrictEqual({ + await expect(duskAPI.getLatestChainInfo(node)).resolves.toStrictEqual({ blocks: mockData.apiLatestChainInfo.data.blocks.map(transformBlock), - transactions: - mockData.apiLatestChainInfo.data.transactions.map(transformTransaction), + transactions: mockData.apiLatestChainInfo.data.transactions.map( + transformAPITransaction + ), }); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL("latest"), + getAPIExpectedURL("latest"), apiGetOptions ); }); - it("should expose a method to retrieve the market data", () => { + it("should expose a method to retrieve the market data", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiMarketData)); - expect(duskAPI.getMarketData()).resolves.toStrictEqual({ + await expect(duskAPI.getMarketData()).resolves.toStrictEqual({ currentPrice: mockData.apiMarketData.market_data.current_price, marketCap: mockData.apiMarketData.market_data.market_cap, }); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( new URL( - getExpectedURL("quote") + getAPIExpectedURL("quote") .toString() .replace(/(\?).+$/, "$1") ), @@ -100,72 +104,91 @@ describe("duskAPI", () => { ); }); - it("should expose a method to retrieve the node locations", () => { + it("should expose a method to retrieve the node locations", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiNodeLocations)); - expect(duskAPI.getNodeLocations(node)).resolves.toStrictEqual( + await expect(duskAPI.getNodeLocations(node)).resolves.toStrictEqual( mockData.apiNodeLocations.data ); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL("locations"), + getAPIExpectedURL("locations"), apiGetOptions ); }); - it("should expose a method to retrieve the statistics", () => { + it("should expose a method to retrieve the statistics", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiStats)); - expect(duskAPI.getStats(node)).resolves.toStrictEqual(mockData.apiStats); + await expect(duskAPI.getStats(node)).resolves.toStrictEqual( + mockData.apiStats + ); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL("stats"), + getAPIExpectedURL("stats"), apiGetOptions ); }); - it("should expose a method to retrieve a single transaction", () => { - fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiTransaction)); + it("should expose a method to retrieve a single transaction", async () => { + fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.gqlTransaction)); - expect(duskAPI.getTransaction(node, fakeID)).resolves.toStrictEqual( - transformTransaction(mockData.apiTransaction.data[0]) + await expect(duskAPI.getTransaction(node, fakeID)).resolves.toStrictEqual( + transformTransaction(mockData.gqlTransaction.tx) ); expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL(`transactions/${fakeID}`), - apiGetOptions - ); + expect(fetchSpy.mock.calls[0][0]).toBe(gqlExpectedURL); + expect(fetchSpy.mock.calls[0][1]).toMatchInlineSnapshot(` + { + "body": "{"data":"\\n \\nfragment TransactionInfo on SpentTransaction {\\n\\tblockHash,\\n\\tblockHeight,\\n\\tblockTimestamp,\\n err,\\n\\tgasSpent,\\n\\tid,\\n tx {\\n callData {\\n contractId,\\n data,\\n fnName\\n },\\n gasLimit,\\n gasPrice,\\n id\\n }\\n}\\n\\n query($id: String!) { tx(hash: $id) {...TransactionInfo} }\\n ","topic":"gql"}", + "headers": { + "Accept": "application/json", + "Accept-Charset": "utf-8", + "Content-Type": "application/json", + "Rusk-gqlvar-id": "some-id", + }, + "method": "POST", + } + `); }); - it("should expose a method to retrieve the details of a single transaction", () => { + it("should expose a method to retrieve the details of a single transaction", async () => { fetchSpy.mockResolvedValueOnce( - makeOKResponse(mockData.apiTransactionDetails) + makeOKResponse(mockData.gqlTransactionDetails) ); - expect(duskAPI.getTransactionDetails(node, fakeID)).resolves.toBe( - mockData.apiTransactionDetails.data.json - ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL(`transactions/${fakeID}/details`), - apiGetOptions + await expect(duskAPI.getTransactionDetails(node, fakeID)).resolves.toBe( + mockData.gqlTransactionDetails.tx.raw ); + expect(fetchSpy.mock.calls[0][0]).toBe(gqlExpectedURL); + expect(fetchSpy.mock.calls[0][1]).toMatchInlineSnapshot(` + { + "body": "{"data":"query($id: String!) { tx(hash: $id) { raw } }","topic":"gql"}", + "headers": { + "Accept": "application/json", + "Accept-Charset": "utf-8", + "Content-Type": "application/json", + "Rusk-gqlvar-id": "some-id", + }, + "method": "POST", + } + `); }); - it("should expose a method to retrieve the list of transactions", () => { + it("should expose a method to retrieve the list of transactions", async () => { fetchSpy.mockResolvedValueOnce(makeOKResponse(mockData.apiTransactions)); - expect(duskAPI.getTransactions(node)).resolves.toStrictEqual( - mockData.apiTransactions.data.map(transformTransaction) + await expect(duskAPI.getTransactions(node)).resolves.toStrictEqual( + mockData.apiTransactions.data.map(transformAPITransaction) ); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( - getExpectedURL("transactions"), + getAPIExpectedURL("transactions"), apiGetOptions ); }); - it("should return a rejected promise, with the original Response in the error's `cause` property, for a 4xx error", () => { + it("should return a rejected promise, with the original Response in the error's `cause` property, for a 4xx error", async () => { /** * @template T * @typedef {{[K in keyof T]: T[K] extends Function ? K : never}[keyof T]} Methods @@ -175,17 +198,19 @@ describe("duskAPI", () => { Object.keys(duskAPI).filter((k) => typeof k === "function") ); - apiMethods.forEach((method) => { + for (const apiMethod of apiMethods) { const notFoundResponse = new Response("", { status: 404 }); fetchSpy.mockResolvedValueOnce(notFoundResponse); - expect(() => duskAPI[method]("foo/bar", "some-id")).rejects.toThrow( + await expect(() => + duskAPI[apiMethod]("foo/bar", "some-id") + ).rejects.toThrow( expect.objectContaining({ cause: notFoundResponse, }) ); - }); + } }); it("should be able to make the correct request whether the endpoint in env vars ends with a trailing slash or not", () => { @@ -210,14 +235,14 @@ describe("duskAPI", () => { vi.unstubAllEnvs(); }); - it("should expose a method to search for blocks and transactions", () => { + it("should expose a method to search for blocks and transactions", async () => { fetchSpy.mockResolvedValueOnce( makeOKResponse(mockData.apiSearchBlockResult) ); const query = "some search string"; - expect(duskAPI.search(node, query)).resolves.toStrictEqual( + await expect(duskAPI.search(node, query)).resolves.toStrictEqual( transformSearchResult(mockData.apiSearchBlockResult) ); expect(fetchSpy).toHaveBeenCalledTimes(1); diff --git a/explorer/src/lib/services/duskAPI.js b/explorer/src/lib/services/duskAPI.js index 3df9dec90..b9d0204b3 100644 --- a/explorer/src/lib/services/duskAPI.js +++ b/explorer/src/lib/services/duskAPI.js @@ -1,40 +1,84 @@ -import { getKey, getPath, mapWith } from "lamb"; +import { + fromPairs, + getKey, + getPath, + isUndefined, + mapWith, + ownPairs, + pipe, + unless, + updateAt, +} from "lamb"; import { failureToRejection } from "$lib/dusk/http"; import { + transformAPITransaction, transformBlock, transformSearchResult, transformTransaction, } from "$lib/chain-info"; +import * as gqlQueries from "./gql-queries"; + /** @type {(blocks: APIBlock[]) => Block[]} */ const transformBlocks = mapWith(transformBlock); /** @type {(transactions: APITransaction[]) => Transaction[]} */ -const transformTransactions = mapWith(transformTransaction); +const transformTransactions = mapWith(transformAPITransaction); /** @type {(s: string) => string} */ const ensureTrailingSlash = (s) => (s.endsWith("/") ? s : `${s}/`); +/** + * Adds the `Rusk-gqlvar-` prefix to all + * keys of the given object. + * Returns `undefined` if the input is `undefined`. + */ +const toHeadersVariables = unless( + isUndefined, + pipe([ownPairs, mapWith(updateAt(0, (k) => `Rusk-gqlvar-${k}`)), fromPairs]) +); + /** * @param {string} endpoint * @param {Record | undefined} params * @returns {URL} */ -const makeURL = (endpoint, params) => +const makeAPIURL = (endpoint, params) => new URL( `${endpoint}?${new URLSearchParams(params)}`, ensureTrailingSlash(import.meta.env.VITE_API_ENDPOINT) ); +/** + * @param {string} node + * @param {{ query: string, variables: Record | undefined }} queryInfo + */ +const gqlGet = (node, queryInfo) => + fetch(`https://${node}/02/Chain`, { + body: JSON.stringify({ + data: queryInfo.query, + topic: "gql", + }), + headers: { + Accept: "application/json", + "Accept-Charset": "utf-8", + "Content-Type": "application/json", + ...toHeadersVariables(queryInfo.variables), + }, + method: "POST", + }) + .then(failureToRejection) + .then((res) => res.json()); + /** * @param {string} endpoint * @param {Record} [params] * @returns {Promise} */ const apiGet = (endpoint, params) => - fetch(makeURL(endpoint, params), { + fetch(makeAPIURL(endpoint, params), { headers: { Accept: "application/json", "Accept-Charset": "utf-8", @@ -111,8 +155,8 @@ export default { * @returns {Promise} */ getTransaction(node, id) { - return apiGet(`transactions/${id}`, { node }) - .then(getPath("data.0")) + return gqlGet(node, gqlQueries.getTransactionQueryInfo(id)) + .then(getKey("tx")) .then(transformTransaction); }, @@ -122,8 +166,8 @@ export default { * @returns {Promise} */ getTransactionDetails(node, id) { - return apiGet(`transactions/${id}/details`, { node }).then( - getPath("data.json") + return gqlGet(node, gqlQueries.getTransactionDetailsQueryInfo(id)).then( + getPath("tx.raw") ); }, diff --git a/explorer/src/lib/services/gql-queries.js b/explorer/src/lib/services/gql-queries.js new file mode 100644 index 000000000..5eb137ee8 --- /dev/null +++ b/explorer/src/lib/services/gql-queries.js @@ -0,0 +1,35 @@ +const transactionFragment = ` +fragment TransactionInfo on SpentTransaction { + blockHash, + blockHeight, + blockTimestamp, + err, + gasSpent, + id, + tx { + callData { + contractId, + data, + fnName + }, + gasLimit, + gasPrice, + id + } +} +`; + +/** @param {string} id */ +export const getTransactionQueryInfo = (id) => ({ + query: ` + ${transactionFragment} + query($id: String!) { tx(hash: $id) {...TransactionInfo} } + `, + variables: { id }, +}); + +/** @param {string} id */ +export const getTransactionDetailsQueryInfo = (id) => ({ + query: "query($id: String!) { tx(hash: $id) { raw } }", + variables: { id }, +}); diff --git a/explorer/src/routes/__tests__/page.spec.js b/explorer/src/routes/__tests__/page.spec.js index c03f3e97c..ec3e7eb11 100644 --- a/explorer/src/routes/__tests__/page.spec.js +++ b/explorer/src/routes/__tests__/page.spec.js @@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/svelte"; import { get } from "svelte/store"; import { duskAPI } from "$lib/services"; -import { transformBlock, transformTransaction } from "$lib/chain-info"; +import { transformAPITransaction, transformBlock } from "$lib/chain-info"; import { apiLatestChainInfo } from "$lib/mock-data"; import { appStore } from "$lib/stores"; @@ -17,8 +17,9 @@ describe("home page", () => { .spyOn(duskAPI, "getLatestChainInfo") .mockResolvedValue({ blocks: apiLatestChainInfo.data.blocks.map(transformBlock), - transactions: - apiLatestChainInfo.data.transactions.map(transformTransaction), + transactions: apiLatestChainInfo.data.transactions.map( + transformAPITransaction + ), }); afterEach(() => { diff --git a/explorer/src/routes/transactions/__tests__/page.spec.js b/explorer/src/routes/transactions/__tests__/page.spec.js index ca4446034..b12fb7b40 100644 --- a/explorer/src/routes/transactions/__tests__/page.spec.js +++ b/explorer/src/routes/transactions/__tests__/page.spec.js @@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/svelte"; import { get } from "svelte/store"; import { duskAPI } from "$lib/services"; -import { transformTransaction } from "$lib/chain-info"; +import { transformAPITransaction } from "$lib/chain-info"; import { appStore } from "$lib/stores"; import { apiTransactions } from "$lib/mock-data"; @@ -15,7 +15,7 @@ describe("Transactions page", () => { const { fetchInterval, network } = get(appStore); const getTransactionSpy = vi .spyOn(duskAPI, "getTransactions") - .mockResolvedValue(apiTransactions.data.map(transformTransaction)); + .mockResolvedValue(apiTransactions.data.map(transformAPITransaction)); afterEach(() => { cleanup(); diff --git a/explorer/src/routes/transactions/transaction/__tests__/page.spec.js b/explorer/src/routes/transactions/transaction/__tests__/page.spec.js index ea1e18fc2..4cf063109 100644 --- a/explorer/src/routes/transactions/transaction/__tests__/page.spec.js +++ b/explorer/src/routes/transactions/transaction/__tests__/page.spec.js @@ -5,8 +5,8 @@ import { duskAPI } from "$lib/services"; import { transformTransaction } from "$lib/chain-info"; import { apiMarketData, - apiTransaction, - apiTransactionDetails, + gqlTransaction, + gqlTransactionDetails, } from "$lib/mock-data"; import TransactionDetails from "../+page.svelte"; @@ -22,10 +22,10 @@ describe("Transaction Details", () => { const getTransactionSpy = vi .spyOn(duskAPI, "getTransaction") - .mockResolvedValue(transformTransaction(apiTransaction.data[0])); + .mockResolvedValue(transformTransaction(gqlTransaction.tx)); const getPayloadSpy = vi .spyOn(duskAPI, "getTransactionDetails") - .mockResolvedValue(apiTransactionDetails.data.json); + .mockResolvedValue(gqlTransactionDetails.tx.raw); const getMarketDataSpy = vi .spyOn(duskAPI, "getMarketData") .mockResolvedValue({