diff --git a/frontend/package.json b/frontend/package.json index aa2fb67ae..abae9b6df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "@adraffy/ens-normalize": "1.9.2", "@metamask/jazzicon": "^2.0.0", "@quasar/extras": "^1.15.8", - "@umbracash/umbra-js": "0.1.6", + "@umbracash/umbra-js": "0.2.1", "@uniswap/token-lists": "^1.0.0-beta.19", "@unstoppabledomains/resolution": "8.5.0", "@web3-onboard/coinbase": "2.2.7", diff --git a/frontend/src/pages/AccountReceive.vue b/frontend/src/pages/AccountReceive.vue index 84eb743a4..93e8c6aa0 100644 --- a/frontend/src/pages/AccountReceive.vue +++ b/frontend/src/pages/AccountReceive.vue @@ -178,6 +178,7 @@ function useScan() { setScanPrivateKey, scanPrivateKey, resetScanSettings: resetScanSettingsInSettingsStore, + getRegisteredBlockNumber, } = useSettingsStore(); const { signer, userAddress: userWalletAddress, isAccountSetup, provider } = useWalletStore(); @@ -484,6 +485,7 @@ function useScan() { // Default scan behavior for await (const announcementsBatch of umbra.value.fetchSomeAnnouncements( + getRegisteredBlockNumber(), signer.value, userWalletAddress.value, overrides diff --git a/frontend/src/store/settings.ts b/frontend/src/store/settings.ts index 82b2ad85b..f6b6ac70e 100644 --- a/frontend/src/store/settings.ts +++ b/frontend/src/store/settings.ts @@ -12,6 +12,7 @@ const settings = { language: 'language', sendHistorySave: 'send-history-save', UmbraApiVersion: 'umbra-api-version', + registeredBlockNumber: 'registered-block-number', }; @@ -29,6 +30,7 @@ const startBlock = ref(undefined); // block number to start const endBlock = ref(undefined); // block number to scan through const scanPrivateKey = ref(); // private key entered when scanning const lastWallet = ref(); // name of last wallet used +const registeredBlockNumber = ref(undefined); // block number of the when the user registered const params = new URLSearchParams(window.location.search); const paramLocale = params.get('locale') || undefined; @@ -43,7 +45,9 @@ export default function useSettingsStore() { lastWallet.value = LocalStorage.getItem(settings.lastWallet) ? String(LocalStorage.getItem(settings.lastWallet)) : undefined; - + registeredBlockNumber.value = LocalStorage.getItem(settings.registeredBlockNumber) + ? Number(LocalStorage.getItem(settings.registeredBlockNumber)) + : undefined; }); setLanguage( paramLocale @@ -140,6 +144,14 @@ export default function useSettingsStore() { LocalStorage.remove(settings.UmbraApiVersion); } + function getRegisteredBlockNumber() { + return registeredBlockNumber.value; + } + + function setRegisteredBlockNumber(blockNumber: number) { + registeredBlockNumber.value = blockNumber; + LocalStorage.set(settings.registeredBlockNumber, blockNumber); + } return { toggleDarkMode, @@ -162,5 +174,7 @@ export default function useSettingsStore() { getUmbraApiVersion, setUmbraApiVersion, clearUmbraApiVersion, + getRegisteredBlockNumber, + setRegisteredBlockNumber, }; } diff --git a/frontend/src/store/wallet.ts b/frontend/src/store/wallet.ts index e886d1203..737ed505a 100644 --- a/frontend/src/store/wallet.ts +++ b/frontend/src/store/wallet.ts @@ -632,19 +632,13 @@ const hasSetPublicKeysLegacy = async (name: string, provider: Provider) => { // Helper method to check if user has registered public keys in the StealthKeyRegistry async function getRegisteredStealthKeys(account: string, provider: Provider) { - let retryCounter = 0; - while (retryCounter < 3) { - try { - console.log(`getting stealth keys for ${account}, try ${retryCounter + 1} of 3`); - const stealthPubKeys = await utils.lookupRecipient(account, provider); // throws if no keys found - return stealthPubKeys; - } catch (err) { - window.logger.warn(err); - retryCounter++; - if (retryCounter < 3) { - await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait for 2 seconds - } - } + const { setRegisteredBlockNumber } = useSettingsStore(); + try { + const registrationInfo = await utils.lookupRecipient(account, provider); // throws if no keys found + setRegisteredBlockNumber(Number(registrationInfo.block)); + return registrationInfo; + } catch (err) { + window.logger.warn(err); + return null; } - return null; } diff --git a/umbra-js/.npmignore b/umbra-js/.npmignore index e69de29bb..2eea525d8 100644 --- a/umbra-js/.npmignore +++ b/umbra-js/.npmignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/umbra-js/package.json b/umbra-js/package.json index a82d9e589..95b81a593 100644 --- a/umbra-js/package.json +++ b/umbra-js/package.json @@ -1,6 +1,6 @@ { "name": "@umbracash/umbra-js", - "version": "0.1.6", + "version": "0.2.1", "description": "Send and receive stealth payments", "main": "build/src/index.js", "types": "build/src/index.d.ts", diff --git a/umbra-js/src/classes/Umbra.ts b/umbra-js/src/classes/Umbra.ts index 7271dfab8..31ef4e446 100644 --- a/umbra-js/src/classes/Umbra.ts +++ b/umbra-js/src/classes/Umbra.ts @@ -30,13 +30,14 @@ import { invalidStealthAddresses, getEthSweepGasInfo, lookupRecipient, + getBlockNumberUserRegistered, assertSupportedAddress, checkSupportedAddresses, - getBlockNumberUserRegistered, + recursiveGraphFetch, } from '../utils/utils'; import { Umbra as UmbraContract, Umbra__factory, ERC20__factory } from '../typechain'; import { ETH_ADDRESS, UMBRA_BATCH_SEND_ABI } from '../utils/constants'; -import type { Announcement, ChainConfig, EthersProvider, GraphFilterOverride, ScanOverrides, SendOverrides, SubgraphAnnouncement, UserAnnouncement, AnnouncementDetail, SendBatch, SendData} from '../types'; // prettier-ignore +import type { Announcement, ChainConfig, EthersProvider, ScanOverrides, SendOverrides, SubgraphAnnouncement, UserAnnouncement, AnnouncementDetail, SendBatch, SendData} from '../types'; // prettier-ignore // Mapping from chainId to contract information const umbraAddress = '0xFb2dc580Eed955B528407b4d36FfaFe3da685401'; // same on all supported networks @@ -72,7 +73,7 @@ const chainConfigs: Record = { * @notice Helper method to parse chainConfig input and return a valid chain configuration * @param chainConfig Supported chainID as number, or custom ChainConfig */ -const parseChainConfig = (chainConfig: ChainConfig | number) => { +export const parseChainConfig = (chainConfig: ChainConfig | number) => { if (!chainConfig) { throw new Error('chainConfig not provided'); } @@ -372,7 +373,7 @@ export class Umbra { } /** - * @notice Fetches all Umbra event logs using Goldsky, if available, falling back to RPC if not + * @notice Fetches all Umbra event logs using a subgraph, if available, falling back to RPC if not * @param overrides Override the start and end block used for scanning; * @returns A list of Announcement events supplemented with additional metadata, such as the sender, block, * timestamp, and txhash @@ -397,7 +398,7 @@ export class Umbra { return filtered.filter((i) => i !== null) as AnnouncementDetail[]; }; - // Try querying events using Goldsky, fallback to querying logs. + // Try querying events using a subgraph, fallback to querying logs. if (this.chainConfig.subgraphUrl) { try { for await (const subgraphAnnouncements of this.fetchAllAnnouncementsFromSubgraph(startBlock, endBlock)) { @@ -417,17 +418,23 @@ export class Umbra { /** * @notice Fetches Umbra event logs starting from the block user registered their stealth keys in using - * Goldsky, if available, falling back to RPC if not + * a subgraph, if available, falling back to RPC if not + * @param possibleRegisteredBlockNumber Block number when user registered their stealth keys (if known) + * @param Signer Signer with provider to use for fetching the block number (if not known) from the StealthKeyRegistry contract + * @param address Address of the user for fetching the block number (if not known) from the subgraph or StealthKeyRegistry contract * @param overrides Override the start and end block used for scanning; * @returns A list of Announcement events supplemented with additional metadata, such as the sender, block, * timestamp, and txhash + * @dev If the registered block number is not known, it will be fetched from the subgraph or the StealthKeyRegistry contract */ async *fetchSomeAnnouncements( + possibleRegisteredBlockNumber: number | undefined, Signer: JsonRpcSigner, address: string, overrides: ScanOverrides = {} ): AsyncGenerator { - const registeredBlockNumber = await getBlockNumberUserRegistered(address, Signer.provider); + const registeredBlockNumber = + possibleRegisteredBlockNumber || (await getBlockNumberUserRegistered(address, Signer.provider, this.chainConfig)); // Get start and end blocks to scan events for const startBlock = overrides.startBlock || registeredBlockNumber || this.chainConfig.startBlock; const endBlock = overrides.endBlock || 'latest'; @@ -437,7 +444,7 @@ export class Umbra { } /** - * @notice Fetches all Umbra event logs using Goldsky + * @notice Fetches all Umbra event logs using a subgraph * @param startBlock Scanning start block * @param endBlock Scannding end block * @returns A list of Announcement events supplemented with additional metadata, such as the sender, block, @@ -732,67 +739,6 @@ export class Umbra { // ============================== PRIVATE, FUNCTIONAL HELPER METHODS ============================== -/** - * @notice Generic method to recursively grab every 'page' of results - * @dev NOTE: the query MUST return the ID field - * @dev Modifies from: https://github.com/dcgtc/dgrants/blob/f5a783524d0b56eea12c127b2146fba8fb9273b4/app/src/utils/utils.ts#L443 - * @dev Relevant docs: https://thegraph.com/docs/developer/graphql-api#example-3 - * @dev Lives outside of the class instance because user's should not need access to this method - * @dev TODO support node.js by replacing reliance on browser's fetch module with https://github.com/paulmillr/micro-ftch - * @param url the url we will recursively fetch from - * @param key the key in the response object which holds results - * @param query a function which will return the query string (with the page in place) - * @param before the current array of objects - */ -async function* recursiveGraphFetch( - url: string, - key: string, - query: (filter: string) => string, - before: any[] = [], - overrides?: GraphFilterOverride -): AsyncGenerator { - // retrieve the last ID we collected to use as the starting point for this query - const fromId = before.length ? (before[before.length - 1].id as string | number) : false; - let startBlockFilter = ''; - let endBlockFilter = ''; - const startBlock = overrides?.startBlock ? overrides.startBlock.toString() : ''; - const endBlock = overrides?.endBlock ? overrides?.endBlock.toString() : ''; - - if (startBlock) { - startBlockFilter = `block_gte: "${startBlock}",`; - } - - if (endBlock && endBlock !== 'latest') { - endBlockFilter = `block_lte: "${endBlock}",`; - } - // Fetch this 'page' of results - please note that the query MUST return an ID - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: query(` - first: 1000, - orderBy: id, - orderDirection: desc, - where: { - ${fromId ? `id_lt: "${fromId}",` : ''} - ${startBlockFilter} - ${endBlockFilter} - } - `), - }), - }); - - // Resolve the json - const json = await res.json(); - - // If there were results on this page yield the results then query the next page, otherwise do nothing. - if (json.data[key].length) { - yield json.data[key]; // yield the data for this page - yield* recursiveGraphFetch(url, key, query, [...before, ...json.data[key]], overrides); // yield the data for the next pages - } -} - /** * @notice Tries withdrawing ETH from a stealth address on behalf of a user * @dev Attempts multiple retries before returning an error. Retries only occur if there was an diff --git a/umbra-js/src/types.ts b/umbra-js/src/types.ts index b50285e0b..351b3b197 100644 --- a/umbra-js/src/types.ts +++ b/umbra-js/src/types.ts @@ -118,6 +118,20 @@ export interface UserAnnouncement { txHash: string; } +// StealthKeyChanged event data received from subgraph queries +export interface SubgraphStealthKeyChangedEvent { + block: string; + from: string; + id: string; // the subgraph uses an ID of `timestamp-logIndex` + registrant: string; + spendingPubKeyPrefix: BigNumber; + spendingPubKey: BigNumber; + timestamp: string; + txHash: string; + viewingPubKeyPrefix: BigNumber; + viewingPubKey: BigNumber; +} + export interface SendBatch { token: string; amount: BigNumberish; @@ -135,4 +149,5 @@ export interface SendData { export type GraphFilterOverride = { startBlock?: number | string; endBlock?: number | string; + registrant?: string; }; diff --git a/umbra-js/src/utils/utils.ts b/umbra-js/src/utils/utils.ts index 617d0ce88..8ccbd26ab 100644 --- a/umbra-js/src/utils/utils.ts +++ b/umbra-js/src/utils/utils.ts @@ -24,8 +24,17 @@ import { ens, cns } from '..'; import { default as Resolution } from '@unstoppabledomains/resolution'; import { StealthKeyRegistry } from '../classes/StealthKeyRegistry'; import { TxHistoryProvider } from '../classes/TxHistoryProvider'; -import { EthersProvider, TransactionResponseExtended } from '../types'; +import { KeyPair } from '../classes/KeyPair'; +import { + EthersProvider, + TransactionResponseExtended, + GraphFilterOverride, + ChainConfig, + SubgraphStealthKeyChangedEvent, + ScanOverrides, +} from '../types'; import { StealthKeyChangedEvent } from 'src/typechain/contracts/StealthKeyRegistry'; +import { parseChainConfig } from '../classes/Umbra'; // Lengths of various properties when represented as full hex strings export const lengths = { @@ -198,8 +207,8 @@ export async function toAddress(name: string, provider: EthersProvider) { /** * @notice Returns public keys from the recipientId * @dev When providing a public key, transaction hash, or address with advanced mode, the spending and viewing - * public keys will be the same. Only keys retrieved from the StealthKeyRegistry will have different spending - * and viewing keys + * public keys will be the same. Only keys retrieved from the StealthKeyRegistry (or the subgraph) will have different spending + * and viewing keys. Additionally, the block number when the user registered will be returned. * @param id Recipient identifier, must be an ENS name, CNS name, address, transaction hash, or public key * @param provider ethers provider to use * @param options Object containing lookup options: @@ -216,6 +225,7 @@ export async function lookupRecipient( supportTxHash, }: { advanced?: boolean; supportPubKey?: boolean; supportTxHash?: boolean } = {} ) { + const chainId = (await provider.getNetwork()).chainId; // Check if identifier is a public key. If so we just return that directly const isPublicKey = id.length === 132 && isHexString(id); if (supportPubKey && isPublicKey) { @@ -235,10 +245,36 @@ export async function lookupRecipient( // ENS name, CNS name, or address, so we resolve it to an address const address = await toAddress(id, provider); // throws if an invalid address is provided - // If we're not using advanced mode, use the StealthKeyRegistry + // If we're not using advanced mode, use the StealthKeyRegistry events if (!advanced) { - const registry = new StealthKeyRegistry(provider); - return registry.getStealthKeys(address); + // Fetch the stealth key registry event from the the registry contract and fall back to subgraph if the registry contract fetch returns an error + try { + const registry = new StealthKeyRegistry(provider); + const { spendingPublicKey, viewingPublicKey } = await registry.getStealthKeys(address); + return { spendingPublicKey, viewingPublicKey }; + } catch (error) { + if (error instanceof Error) { + console.log('StrealthKey Registry fetch error: ', error.message); + } else { + console.log('An unknown error occurred: ', error); + } + console.log('Error using Registry contract to lookup receipient stealth keys, will query subgraph'); + const chainConfig = parseChainConfig(chainId); + const stealthKeyChangedEvent = await getMostRecentSubgraphStealthKeyChangedEventFromAddress(address, chainConfig); + const spendingPublicKey = KeyPair.getUncompressedFromX( + stealthKeyChangedEvent.spendingPubKey, + stealthKeyChangedEvent.spendingPubKeyPrefix.toString() + ); + const viewingPublicKey = KeyPair.getUncompressedFromX( + stealthKeyChangedEvent.viewingPubKey, + stealthKeyChangedEvent.viewingPubKeyPrefix.toString() + ); + return { + spendingPublicKey: spendingPublicKey, + viewingPublicKey: viewingPublicKey, + block: stealthKeyChangedEvent.block, + }; + } } // Otherwise, get public key based on the most recent transaction sent by that address @@ -249,21 +285,187 @@ export async function lookupRecipient( return { spendingPublicKey: publicKey, viewingPublicKey: publicKey }; } -export async function getBlockNumberUserRegistered(address: string, provider: StaticJsonRpcProvider) { +export async function getBlockNumberUserRegistered( + address: string, + provider: StaticJsonRpcProvider, + chainConfig: ChainConfig +) { + // Fetch the stealth key registry event from the subgraph and fall back to the registry contract if the subgraph returns an error address = getAddress(address); // address input validation - const registry = new StealthKeyRegistry(provider); - const filter = registry._registry.filters.StealthKeyChanged(address, null, null, null, null); try { - const timeout = (ms: number) => new Promise((reject) => setTimeout(() => reject(new Error('timeout')), ms)); - const stealthKeyLogsPromise = registry._registry.queryFilter(filter); - const stealthKeyLogs = (await Promise.race([stealthKeyLogsPromise, timeout(3000)])) as StealthKeyChangedEvent[]; - const registryBlock = sortStealthKeyLogs(stealthKeyLogs)[0]?.blockNumber || undefined; - return registryBlock; + console.log('Using subgraph to get block number when user registered'); + const stealthKeyChangedEvent = await getMostRecentSubgraphStealthKeyChangedEventFromAddress(address, chainConfig); + console.log(`stealthKeyChangedEvent.block: ${stealthKeyChangedEvent.block}`); + return stealthKeyChangedEvent.block; } catch { - return undefined; + console.log('Error using subgraph to get block number when user registered, will query registry contract'); + const registry = new StealthKeyRegistry(provider); + const filter = registry._registry.filters.StealthKeyChanged(address, null, null, null, null); + try { + const timeout = (ms: number) => new Promise((reject) => setTimeout(() => reject(new Error('timeout')), ms)); + const stealthKeyLogsPromise = registry._registry.queryFilter(filter); + const stealthKeyLogs = (await Promise.race([stealthKeyLogsPromise, timeout(3000)])) as StealthKeyChangedEvent[]; + const registryBlock = sortStealthKeyLogs(stealthKeyLogs)[0]?.blockNumber || undefined; + return registryBlock; + } catch { + return undefined; + } + } +} + +export async function getMostRecentSubgraphStealthKeyChangedEventFromAddress( + address: string, + chainConfig: ChainConfig, + overrides: ScanOverrides = {} +): Promise { + const startBlock = overrides.startBlock || chainConfig.startBlock; + const endBlock = overrides.endBlock || 'latest'; + + // Fetch stealth key changed events from the subgraph + const stealthKeyChangedEvents = fetchAllStealthKeyChangedEventsForRecipientAddressFromSubgraph( + startBlock, + endBlock, + address, + chainConfig + ); + let theEvent: SubgraphStealthKeyChangedEvent | undefined; + try { + for await (const event of stealthKeyChangedEvents) { + for (let i = 0; i < event.length; i++) { + if (theEvent) { + console.log( + `We found a previous StealthKeyChangedEvent for address ${address} in the subgraph at block ${event[i].block} with transaction hash ${event[i].txHash}` + ); + } else { + theEvent = event[i]; + console.log( + `We found a StealthKeyChangedEvent for address ${address} in the subgraph at block ${event[i].block} with transaction hash ${event[i].txHash}` + ); + } + } + } + } catch (error) { + throw new Error(`Address ${address} has not registered stealth keys. Please ask them to setup their Umbra account`); // prettier-ignore + } + + if (!theEvent) { + console.log(`Searched the subgraph, but found no StealthKeyChangedEvents for address ${address}`); + throw new Error('No stealthKeyChangedEvents found matching address in subgraph'); + } + return theEvent; +} + +/** + * @notice Fetches all Umbra event logs using a subgraph + * @param startBlock Scanning start block + * @param endBlock Scannding end block + * @returns A list of StealthKeyChanged events supplemented with additional metadata, such as the sender, block, + * timestamp, txhash, and the subgraph identifier + */ +async function* fetchAllStealthKeyChangedEventsForRecipientAddressFromSubgraph( + startBlock: string | number, + endBlock: string | number, + registrant: string, + chainConfig: ChainConfig +): AsyncGenerator { + if (!chainConfig.subgraphUrl) { + console.log('throwing error because subgraphUrl is not defined'); + throw new Error('Subgraph URL must be defined to fetch via subgraph'); + } + + // Query subgraph + for await (const stealthKeyChangedEvents of recursiveGraphFetch( + chainConfig.subgraphUrl, + 'stealthKeyChangedEntities', + (filter: string) => `{ + stealthKeyChangedEntities(${filter}) { + block + from + id + registrant + spendingPubKey + spendingPubKeyPrefix + timestamp + txHash + viewingPubKey + viewingPubKeyPrefix + } + }`, + [], + { + startBlock, + endBlock, + registrant, + } + )) { + yield stealthKeyChangedEvents; } } +/** + * @notice Generic method to recursively grab every 'page' of results + * @dev NOTE: the query MUST return the ID field + * @dev Modifies from: https://github.com/dcgtc/dgrants/blob/f5a783524d0b56eea12c127b2146fba8fb9273b4/app/src/utils/utils.ts#L443 + * @dev Relevant docs: https://thegraph.com/docs/developer/graphql-api#example-3 + * @dev Lives outside of the class instance because user's should not need access to this method + * @dev TODO support node.js by replacing reliance on browser's fetch module with https://github.com/paulmillr/micro-ftch + * @param url the url we will recursively fetch from + * @param key the key in the response object which holds results + * @param query a function which will return the query string (with the page in place) + * @param before the current array of objects + */ +export async function* recursiveGraphFetch( + url: string, + key: string, + query: (filter: string) => string, + before: any[] = [], + overrides?: GraphFilterOverride +): AsyncGenerator { + // retrieve the last ID we collected to use as the starting point for this query + const fromId = before.length ? (before[before.length - 1].id as string | number) : false; + let startBlockFilter = ''; + let endBlockFilter = ''; + const startBlock = overrides?.startBlock ? overrides.startBlock.toString() : ''; + const endBlock = overrides?.endBlock ? overrides?.endBlock.toString() : ''; + const registrantFilter = overrides?.registrant ? 'registrant: "' + overrides.registrant.toLowerCase() + '"' : ''; + + if (startBlock) { + startBlockFilter = `block_gte: "${startBlock}",`; + } + + if (endBlock && endBlock !== 'latest') { + endBlockFilter = `block_lte: "${endBlock}",`; + } + + // Fetch this 'page' of results - please note that the query MUST return an ID + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + // Gnosis chain is using Goldsky subgraph, which only supports 1000 blocks per query. + query: query(` + first: ${url.includes('umbra-xdai') ? 1000 : 10000}, + orderBy: id, + orderDirection: desc, + where: { + ${fromId ? `id_lt: "${fromId}",` : ''} + ${startBlockFilter} + ${endBlockFilter} + ${registrantFilter} + } + `), + }), + }); + + // Resolve the json + const json = await res.json(); + + // If there were results on this page yield the results then query the next page, otherwise do nothing. + if (json.data[key].length) { + yield json.data[key]; // yield the data for this page + yield* recursiveGraphFetch(url, key, query, [...before, ...json.data[key]], overrides); // yield the data for the next pages + } +} // Sorts stealth key logs in ascending order by block number export function sortStealthKeyLogs(stealthKeyLogs: Event[]) { return stealthKeyLogs.sort(function (a, b) { diff --git a/umbra-js/test/utils.test.ts b/umbra-js/test/utils.test.ts index 37ae2afa5..9374c0c51 100644 --- a/umbra-js/test/utils.test.ts +++ b/umbra-js/test/utils.test.ts @@ -242,6 +242,7 @@ describe('Utilities', () => { ); }); + // prove test failure is related to the ordering of RPC query vs subgraph query, and subgraph URL is not setup it('throws when looking up an address that has not sent a transaction', async () => { const address = '0x0000000000000000000000000000000000000002'; const ethersProvider = new StaticJsonRpcProvider(SEPOLIA_RPC_URL); // otherwise throws with unsupported network since we're on localhost