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

Signature Recovery from Near Transaction Hash #78

Merged
merged 4 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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<Hex>} 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<Hex> {
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<T>[]} promises - An array of promises to race. Each promise should resolve to type `T`.
*
* @returns {Promise<T>} 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<T>(
promises: Promise<T>[]
): Promise<T> {
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"));
}
});
});
});
}
58 changes: 58 additions & 0 deletions tests/unit/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
packPaymasterData,
packSignature,
saltNonceFromMessage,
raceToFirstResolve,
signatureFromTxHash,
} from "../../src/util";

describe("Utility Functions (mostly byte packing)", () => {
Expand Down Expand Up @@ -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"
);
});
});