diff --git a/src/util.ts b/src/util.ts index 0e08b7f..1b1ccec 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,10 @@ -import { EthTransactionParams, Network, SessionRequestParams } from "near-ca"; +import { + EthTransactionParams, + getNetworkId, + Network, + SessionRequestParams, + signatureFromTxHash as sigFromHash, +} from "near-ca"; import { Address, Hex, @@ -11,11 +17,11 @@ import { zeroAddress, toBytes, keccak256, + serializeSignature, } from "viem"; import { PaymasterData, MetaTransaction } from "./types"; -// export const PLACEHOLDER_SIG = encodePacked(["uint48", "uint48"], [0, 0]); type IntLike = Hex | bigint | string | number; @@ -94,3 +100,84 @@ export function saltNonceFromMessage(input: string): string { // Return string for readability and transport. return BigInt(keccak256(toBytes(input))).toString(); } + +/** + * Fetches the signature for a NEAR transaction hash. If an `accountId` is provided, + * it fetches the signature from the appropriate network. Otherwise, it races across + * both `testnet` and `mainnet`. + * + * @param {string} txHash - The NEAR transaction hash for which to fetch the signature. + * @param {string} [accountId] - (Optional) The account ID associated with the transaction. + * Providing this will reduce dangling promises as the network is determined by the account. + * + * @returns {Promise} A promise that resolves to the hex-encoded signature. + * + * @throws Will throw an error if no signature is found for the given transaction hash. + */ +export async function signatureFromTxHash( + txHash: string, + accountId?: string +): Promise { + if (accountId) { + const signature = await sigFromHash( + `https://archival-rpc.${getNetworkId(accountId)}.near.org`, + txHash, + accountId + ); + return packSignature(serializeSignature(signature)); + } + + try { + const signature = await raceToFirstResolve( + ["testnet", "mainnet"].map((network) => + sigFromHash(archiveNode(network), txHash) + ) + ); + return packSignature(serializeSignature(signature)); + } catch { + throw new Error(`No signature found for txHash ${txHash}`); + } +} + +/** + * Utility function to construct an archive node URL for a given NEAR network. + * + * @param {string} networkId - The ID of the NEAR network (e.g., 'testnet', 'mainnet'). + * + * @returns {string} The full URL of the archival RPC node for the specified network. + */ +const archiveNode = (networkId: string): string => + `https://archival-rpc.${networkId}.near.org`; + +/** + * Races an array of promises and resolves with the first promise that fulfills. + * If all promises reject, the function will reject with an error. + * + * @template T + * @param {Promise[]} promises - An array of promises to race. Each promise should resolve to type `T`. + * + * @returns {Promise} A promise that resolves to the value of the first successfully resolved promise. + * + * @throws Will throw an error if all promises reject with the message "All promises rejected". + */ +export async function raceToFirstResolve( + promises: Promise[] +): Promise { + return new Promise((resolve, reject) => { + let rejectionCount = 0; + const totalPromises = promises.length; + + promises.forEach((promise) => { + // Wrap each promise so it only resolves when fulfilled + Promise.resolve(promise) + .then(resolve) // Resolve when any promise resolves + .catch(() => { + rejectionCount++; + // If all promises reject, reject the race with an error + if (rejectionCount === totalPromises) { + reject(new Error("All promises rejected")); + } + }); + }); + }); +} diff --git a/tests/unit/utils.spec.ts b/tests/unit/utils.spec.ts index 3659ee4..cabf233 100644 --- a/tests/unit/utils.spec.ts +++ b/tests/unit/utils.spec.ts @@ -10,6 +10,8 @@ import { packPaymasterData, packSignature, saltNonceFromMessage, + raceToFirstResolve, + signatureFromTxHash, } from "../../src/util"; describe("Utility Functions (mostly byte packing)", () => { @@ -81,4 +83,60 @@ describe("Utility Functions (mostly byte packing)", () => { "26371153660914144112327059280066269158753782528888197421682303285265580464377" ); }); + + it("raceToFirstResolve (success)", async () => { + // Example usage: + const promise1 = new Promise((_, reject) => + setTimeout(() => reject(new Error("Reject 1")), 100) + ); + const promise2 = new Promise((_, reject) => + setTimeout(() => reject(new Error("Reject 2")), 200) + ); + const promise3 = new Promise((resolve) => + setTimeout(resolve, 1, "Resolve") + ); + + await expect( + raceToFirstResolve([promise1, promise2, promise3]) + ).resolves.toBe("Resolve"); + }, 10); + it("raceToFirstResolve (failure)", async () => { + const promise1 = new Promise((_, reject) => + setTimeout(() => reject(new Error("Reject 1")), 10) + ); + const promise2 = new Promise((_, reject) => + setTimeout(() => reject(new Error("Reject 2")), 20) + ); + await expect(raceToFirstResolve([promise1, promise2])).rejects.toThrow( + "All promises rejected" + ); + }); + + it("signatureFromTxHash (mainnet)", async () => { + expect( + await signatureFromTxHash( + "BoKuHRFZ9qZ8gZRCcNS92mQYhEVbHrsUwed6D6CHELhv", + "ping-account.near" + ) + ).toBe( + "0x000000000000000000000000039ae6baaf4e707ca6d7cfe1fec3f1aa1b4978eb34224b347904b9e957a8dbd720da770464a68e3d1bcef1a4a46c3f9d0a358ccaa01669636f765364a17c03f61b" + ); + }); + + it("signatureFromTxHash (testnet)", async () => { + expect( + await signatureFromTxHash( + "BbmJk8W6FNz7cRcFxfVMpBWn9Q9uh99KLkzVyJwmPve8", + "neareth-dev.testnet" + ) + ).toBe( + "0x000000000000000000000000c69b46c006739fa11f3937556fbf7fc846359547c4927cfbc17eea108e13f5340c200541e90ab5d048087ea0e04f11f715115b362df692bf438b59623c574bc11b" + ); + }); + + it("signatureFromTxHash (rejects - doesn't exist)", async () => { + await expect(signatureFromTxHash("fart")).rejects.toThrow( + "No signature found for txHash fart" + ); + }); });