sdk-monorepo/src/lib/core.ts
2023-04-27 17:34:22 +00:00

672 lines
24 KiB
TypeScript

// ts-essentials
import { DeepRequired, MarkOptional } from 'ts-essentials'
// Local types
import { Options as ChainOptions } from 'types/sdk/chain'
import { RelayerProperties as RelayerDataProperties, Options as DataOptions } from 'types/sdk/data'
import { Options, Transactions } from 'types/sdk/core'
import { ZKDepositData, InputFor } from 'types/sdk/crypto'
import { TornadoInstance, TornadoProxy } from 'types/deth'
// External imports
import { BigNumber, providers } from 'ethers'
import { parseUnits } from 'ethers/lib/utils'
import { bigInt } from 'snarkjs'
// @ts-ignore
import { parseIndexableString } from 'pouchdb-collate'
// Local imports
import { Primitives } from 'lib/crypto'
import { ErrorUtils, ObjectUtils, AsyncUtils } from 'lib/utils'
import { Docs, Cache, Keys, Constants, Onchain } from 'lib/data'
import { Contracts, Chain, Synchronizer } from 'lib/chain'
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ FOR SYNCHRONIZATION ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
function tornadoSyncErrorHandler(
err: Error,
numResolvedPromises: number,
callbackIndex: number,
orderIndex: number,
...args: any[]
): void {
err = ErrorUtils.ensureError<Error>(err)
if (err.message.match('context deadline exceeded'))
console.error(
ErrorUtils.getError(
`Context deadline exceeded, stop if more promises do not resolve. Resolved: ${numResolvedPromises}`
)
)
else if (err.message.match('Invalid JSON RPC'))
console.error(
ErrorUtils.getError(`Endpoint returned invalid value (we might be rate limited), retrying.`)
)
else {
err.message += `\nCallback args supplied: [${args.join(', ')}]\n`
throw err
}
}
export class DepositCache extends Cache.Syncable<Docs.Deposit> {
buildDoc(response: any): Docs.Deposit {
return new Docs.Deposit(response)
}
getErrorHandlers(): Array<AsyncUtils.ErrorHandler> {
return [tornadoSyncErrorHandler]
}
getCallbacks(instance: TornadoInstance): Array<AsyncUtils.Callback> {
return [
(fromBlock: number, toBlock: number) => {
return instance.queryFilter(instance.filters.Deposit(null, null, null), fromBlock, toBlock)
}
]
}
}
export class WithdrawalCache extends Cache.Syncable<Docs.Withdrawal> {
buildDoc(response: any): Docs.Withdrawal {
return new Docs.Withdrawal(response)
}
getErrorHandlers(): Array<AsyncUtils.ErrorHandler> {
return [tornadoSyncErrorHandler]
}
getCallbacks(instance: TornadoInstance): Array<AsyncUtils.Callback> {
return [
(fromBlock: number, toBlock: number) => {
return instance.queryFilter(instance.filters.Withdrawal(null, null, null, null), fromBlock, toBlock)
}
]
}
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ CORE ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
type Provider = providers.Provider
type BackupDepositDoc = {
network: string
denomination: string
token: string
invoice?: string
note?: string
}
type RelayerProperties = MarkOptional<
Pick<RelayerDataProperties, 'address' | 'serviceFeePercent' | 'prices'>,
'serviceFeePercent' | 'prices'
>
export class Core extends Synchronizer {
chain: Chain
caches: Map<string, Cache.Base<Docs.Base>>
instances: Map<string, TornadoInstance>
constructor(provider: providers.Provider) {
super()
this.chain = new Chain(provider)
this.caches = new Map<string, Cache.Syncable<Docs.Base>>()
this.instances = new Map<string, TornadoInstance>()
}
connect(provider: Provider): void {
this.chain.provider = provider
}
async getInstances(
keys: Array<{ token: string; denomination: number | string }>
): Promise<Array<TornadoInstance>> {
const chainId = await this.chain.getChainId()
return Promise.all(
keys.map((key) =>
Contracts.getInstance(String(chainId), key.token, String(key.denomination), this.chain.provider)
)
)
}
async getInstance(token: string, denomination: number | string): Promise<TornadoInstance> {
const chainId = String(await this.chain.getChainId())
token = token.toLowerCase()
denomination = String(denomination)
if (this.instances.has(chainId + token + denomination))
return this.instances.get(chainId + token + denomination)!
else return Contracts.getInstance(chainId, token, denomination, this.chain.provider)
}
async getProxy(): Promise<TornadoProxy> {
const chainId = await this.chain.getChainId()
return Contracts.getProxy(String(chainId), this.chain.provider)
}
async buildDepositProof(
instance: TornadoInstance,
relayerProperties: RelayerProperties,
recipientAddress: string,
zkDepositsData: ZKDepositData,
options?: Options.Core.BuildDepositProof
): Promise<Array<string>> {
return (
await this.buildDepositProofs(
instance,
relayerProperties,
[recipientAddress],
[zkDepositsData],
options
)
)[0]
}
/**
* @param instance This is the Tornado Instance which will be withdrawn from.
* @param relayerProperties The properties of the relayer that is going to be used for the withdrawals. If the service fee is 0, it is assumed that there is no relayer, but that a manual wallet withdraw is being made. These properties are included in the ZK proof.
* @param recipientAddresses The recipient addresses which should receive the withdrawals, in order.
* @param zkDepositsData These represent the public and private values, reconstructed from the deposit note, generated during the building of deposit transactions, used for building the proof of knowledge statement for withdrawal, for each withdrawal (in this context).
* @param options Numerous options which most importantly allow a user to specify whether he is buying ETH, whether to check proof data validity and finally to modulate the gas prices which will be used to calculate the gas fees paid to the relayer.
* @returns The proofs for which the user should then decide whether to use a relayer (recommended, but decide carefully which one) or use his own wallet (if needed).
*/
async buildDepositProofs(
instance: TornadoInstance,
relayerProperties: RelayerProperties,
recipientAddresses: Array<string>,
zkDepositsData: Array<ZKDepositData>,
options?: Options.Core.BuildDepositProof
): Promise<Array<Array<string>>> {
// Extract commitments and nullifier hashes
const hexCommitments: string[] = []
const hexNullifierHashes: string[] = []
const purchaseAmounts =
options?.ethPurchaseAmounts ?? new Array(zkDepositsData.length).fill(BigNumber.from(0))
if (zkDepositsData.length !== recipientAddresses.length)
throw ErrorUtils.getError(
'Core.buildDepositProofs: the number of recipients must equal the length of zkDepositsData.'
)
if (zkDepositsData.length !== purchaseAmounts.length)
throw ErrorUtils.getError(
'Core.buildDepositProofs: if purchase amounts is specified, it must equal the length of zkDepositsData.'
)
zkDepositsData.forEach((deposit) => {
hexCommitments.push(deposit.hexCommitment)
hexNullifierHashes.push(deposit.hexNullifierHash)
})
// Determine cache name
const lookupKeys = await Onchain.getInstanceLookupKeys(instance.address)
const name = 'Deposits' + (lookupKeys.network + lookupKeys.token + lookupKeys.denomination).toUpperCase()
// Find all leaves & indices by reading from cache
const [leaves, leafIndices] = await this._findLeavesAndIndices(name, hexCommitments)
const invalidCommitments: string[] = []
this.emit(
'debug',
`\nFound leaves and indices, num leaves: ${leaves.length}, indices: [${leafIndices.join(', ')}]`
)
// Determine whether we will be checking whether notes are spent
const checkSpent = options?.checkNotesSpent !== false
const spentNotes: string[] = []
this.emit('debug', `\nCheck spent notes? => ${checkSpent}`)
// If yes, immediately check it with the supplied Tornado Instance
const checkSpentArray = checkSpent ? await instance.isSpentArray(hexNullifierHashes) : undefined
if (checkSpent) this.emit('debug', `\nSpent array: [${checkSpentArray?.join(', ')}]`)
// Check whether a commitment has not been found in all deposits, meaning that it is invalid
// Also add the invalid commitments. We can do leafIndices[i] because the matched one are concatenated
// at the start
for (let i = 0, len = zkDepositsData.length; i < len; i++) {
if (!leafIndices[i]) invalidCommitments.push(hexCommitments[i])
if (checkSpent && checkSpentArray![i]) spentNotes.push(hexNullifierHashes[i])
}
// If something is wrong, throw
const commitmentsAreInvalid = invalidCommitments.length !== 0
const notesAreSpent = spentNotes.length !== 0
if (commitmentsAreInvalid || notesAreSpent)
throw ErrorUtils.getError(
`Core.buildDepositProofs: ` +
(commitmentsAreInvalid
? `following commitments are invalid:\n\n${invalidCommitments.join('\n')}\n\n`
: '') +
(notesAreSpent
? `${
commitmentsAreInvalid ? 'and ' : ''
}following notes are already spent or invalid:\n\n${spentNotes.join('\n')}\n\n`
: '')
)
// Otherwise, build the merkle tree from the leaves
const merkleTree = Primitives.buildMerkleTree({
height: options?.merkleTreeHeight ?? Constants.MERKLE_TREE_HEIGHT,
leaves: leaves
})
const root: string = BigNumber.from(merkleTree.root()).toHexString()
const checkKnownRoot: boolean = options?.checkKnownRoot ?? true
this.emit('debug', `\nMerkle root: ${root}, check known? => ${checkKnownRoot}`)
// Check whether the root is valid
if (checkKnownRoot && !(await instance.isKnownRoot(root)))
throw ErrorUtils.getError(
'Core.buildDepositProofs: the merkle tree created is not valid, something went wrong with syncing.'
)
// Rest of note invariant arguments
const inputsForProofs: InputFor.ZKProof[] = []
const gasPrice = options?.gasPrice ?? (await this.chain.getGasPrice())
const gasPriceCushion = options?.gasPrice ?? gasPrice.mul(10).div(100)
// In reality, if a manual withdraw is made, we don't differentiate it from a relayer withdraw
// Since it is only serviceFee 0 AND without a token price, the function will not buy more tokens
const serviceFeePercent = relayerProperties.serviceFeePercent ?? 0
const tokenPrice = relayerProperties.prices?.get(lookupKeys.token)
const decimals = BigNumber.from(10).pow(
options?.tokenDecimals ?? (await Onchain.getTokenDecimals(lookupKeys.network, lookupKeys.token))
)
const toWithdraw = BigNumber.from(+lookupKeys.denomination * 10 ** lookupKeys.denomination.length)
.mul(decimals)
.div(10 ** lookupKeys.denomination.length)
const native = lookupKeys.token == (await this.chain.getChainSymbol())
if (!tokenPrice && !native)
throw ErrorUtils.getError(
'Core.buildDepositProofs: a token price MUST be supplied if the token withdrawn is not native.'
)
this.emit(
'debug',
`\nProof building, invariant data: [${[
gasPrice.toString(),
gasPriceCushion.toString(),
serviceFeePercent,
tokenPrice,
decimals.toString(),
toWithdraw.toString()
].join(', ')}]\n`
)
// Compute proofs
for (let i = 0, len = zkDepositsData.length; i < len; i++) {
inputsForProofs.push({
public: {
root: root,
tree: merkleTree,
leafIndex: leafIndices[i],
hexNullifierHash: zkDepositsData[i].hexNullifierHash,
recipientAddress: recipientAddresses[i],
relayerAddress: relayerProperties.address,
fee: this._calcWithdrawalFee(
toWithdraw,
decimals,
gasPrice,
gasPriceCushion,
serviceFeePercent,
purchaseAmounts[i],
// This is our flag whether it's a token or not
native ? undefined : tokenPrice
),
// @ts-expect-error
refund: purchaseAmounts[i] ? bigInt(purchaseAmounts[i].toString()) : bigInt(0)
},
private: {
nullifier: zkDepositsData[i].nullifier,
secret: zkDepositsData[i].secret
}
})
}
return await Primitives.calcDepositProofs(inputsForProofs)
}
private _calcWithdrawalFee(
toWithdraw: BigNumber,
decimals: BigNumber,
gasPrice: BigNumber,
gasPriceCushion: BigNumber,
relayerServiceFee: number,
ethBought: BigNumber,
tokenPriceInEth?: BigNumber
): typeof bigInt {
const factor = 10 ** String(relayerServiceFee).length
const baseRelayerFee = toWithdraw.mul(BigNumber.from(relayerServiceFee * factor)).div(factor)
const txCost = gasPrice.add(gasPriceCushion).mul(5e5)
if (tokenPriceInEth) {
// @ts-expect-error
return bigInt(txCost.add(ethBought).mul(decimals).div(tokenPriceInEth).add(baseRelayerFee).toString())
}
// @ts-expect-error
else return bigInt(txCost.add(baseRelayerFee).toString())
}
/**
* @param instanceName The name of the instance as created in `_sync` function.
* @param commitments The commitments for which the leaf index values are to be noted down extra.
* @returns The result of concatenating the array of leaf indices found by matching them with the provided commitment values, followed by the array of all leaf indices, including all of the formerly mentioned values given that they are valid. Values which have not been matched, meaning probably invalid values, will be `0`.
*/
private async _findLeavesAndIndices(
instanceName: string,
commitments: Array<string>
): Promise<[Array<string>, Array<number>]> {
const indices = new Array<number>(commitments.length).fill(0)
const leaves: Array<string> = []
const cache = this.loadCache<Cache.Base<Docs.Deposit>>(instanceName)
const docs = await cache.db.allDocs()
// If no docs in cache throw and stop
if (docs.total_rows === 0) {
await cache.clear()
throw ErrorUtils.getError(
`Core.buildMerkleTree: events for instance ${instanceName} have not been synchronized.`
)
}
// Otherwise start looking for commitment leaf indices and also pick up all other leafs on the way
for (const row of docs.rows) {
const [, leafIndex, loadedCommitment] = parseIndexableString(row.id)
const index = commitments.findIndex((commitment) => commitment === loadedCommitment)
// If some commitment is found then add the leaf index and remove that commitment
if (index !== -1) {
indices[index] = leafIndex
commitments.splice(index, 1)
}
// In any case push every leaf
leaves.push(BigNumber.from(loadedCommitment).toString())
}
// Concat matched and all leaf indices
return [leaves, indices]
}
async loadNotes(
indexes?: Array<number>,
keys?: Partial<Keys.InstanceLookup>
): Promise<Array<ZKDepositData>> {
const rows = await Cache.loadContents<Docs.Note>('DepositNotes')
let docs: Array<Docs.Note | undefined> = []
let notes: Array<string> = []
if (indexes)
for (let i = 0, len = rows.length; i < len; i++) {
const [index, , ,] = parseIndexableString(rows[i].id)[0]
if (0 < indexes.findIndex(index)) docs.push(rows[i].doc)
}
else docs = rows.map((row) => row.doc)
if (keys)
docs.forEach((doc) => {
const idNetworkMatches = doc && (keys.network ? keys.network === doc?.network : true)
const andTokenSymbolMatches = idNetworkMatches && (keys.token ? keys.token === doc?.token : true)
const lastlyDenominationMatches =
andTokenSymbolMatches && (keys.denomination ? keys.denomination === doc?.denomination : true)
if (lastlyDenominationMatches && doc?.note) notes.push(doc.note)
})
else notes = docs.filter((doc) => ObjectUtils.exists(doc?.note)).map((doc) => doc!.note)
return this.parseNotes(notes)
}
parseNotes(notes: Array<string>): Array<ZKDepositData> {
return notes.map((note) => Primitives.parseNote(note))
}
parseNote(note: string): ZKDepositData {
return this.parseNotes([note])[0]
}
async createInvoice(
instance: TornadoInstance,
options?: Omit<Options.Core.Invoice, 'depositsPerInstance'>
): Promise<Transactions.Invoice> {
let opts: Options.Core.Invoice = options ?? {}
opts.depositsPerInstance = [1]
return (await this.createInvoices([instance], options))[0]
}
async createInvoices(
instances: Array<TornadoInstance>,
options?: Options.Core.Invoice
): Promise<Array<Transactions.Invoice>> {
if (!options) options = {}
if (!options.backup) options.backup = {}
options.backup.invoices = options.backup.invoices ?? true
options.backup.notes = options.backup.notes ?? true
options.doNotPopulate = options.doNotPopulate ?? true
return this.buildDepositTransactions(instances, options)
}
async buildDepositTransaction(
instance: TornadoInstance,
options?: Options.Core.Deposit
): Promise<Transactions.Deposit> {
let opts: Options.Core.Deposit = options ?? {}
opts.depositsPerInstance = [1]
return (await this.buildDepositTransactions([instance], opts))[0]
}
async buildDepositTransactions(
instances: Array<TornadoInstance>,
options?: Options.Core.Deposit
): Promise<Array<Transactions.Deposit>> {
const depositsPerInstance = options?.depositsPerInstance ?? new Array<number>(instances.length).fill(1)
const doNotPopulate = options?.doNotPopulate ?? false
const backupNotes = options?.backup?.notes ?? true
const backupInvoices = options?.backup?.invoices ?? false
if (depositsPerInstance.length != instances.length)
throw ErrorUtils.getError(
'Core.buildDepositTx: number of deposit amount elements must equal the number of instances!'
)
const notesToBackup: Array<BackupDepositDoc> = []
const invoicesToBackup: Array<BackupDepositDoc> = []
const txs: Array<Transactions.Deposit> = []
const chainId = await this.chain.getChainId()
const proxy: TornadoProxy = await Contracts.getProxy(String(chainId), this.chain.provider)
for (let i = 0, nInstances = instances.length; i < nInstances; i++) {
const lookupKeys = await Onchain.getInstanceLookupKeys(instances[i].address)
const pathstring = lookupKeys.network + lookupKeys.token + lookupKeys.denomination
for (let d = 0, nDeposits = depositsPerInstance[i]; d < nDeposits; d++) {
const deposit = Primitives.createDeposit()
const note = Primitives.createNote(deposit.preimage)
if (backupNotes)
notesToBackup.push({
network: lookupKeys.network,
denomination: lookupKeys.denomination,
token: lookupKeys.token,
note: note
})
if (backupInvoices)
invoicesToBackup.push({
network: lookupKeys.network,
denomination: lookupKeys.denomination,
token: lookupKeys.token,
invoice: deposit.hexCommitment
})
if (!doNotPopulate) {
txs.push({
request: {
to: proxy.address,
data: proxy.interface.encodeFunctionData('deposit', [
instances[i].address,
deposit.hexCommitment,
[]
]),
value: lookupKeys.token == 'eth' ? parseUnits(lookupKeys.denomination) : BigNumber.from(0)
},
note: pathstring + '_' + note,
invoice: pathstring + '_' + deposit.hexCommitment
})
} else
txs.push({
request: {},
note: pathstring + '_' + note,
invoice: pathstring + '_' + deposit.hexCommitment
})
}
}
if (backupNotes)
await this._backupDepositData(this.loadCache<Cache.Base<Docs.Note>>('DepositNotes'), notesToBackup)
if (backupInvoices)
await this._backupDepositData(
this.loadCache<Cache.Base<Docs.Invoice>>('DepositInvoices'),
invoicesToBackup
)
return txs
}
private async _backupDepositData<T extends Docs.Note | Docs.Invoice>(
cache: Cache.Base<T>,
backupData: Array<BackupDepositDoc>
): Promise<void> {
let id = +(await cache.db.info()).update_seq
await cache.db
.bulkDocs(
backupData.map((entry) => {
if (entry.note)
return new Docs.Note(++id, entry.network, entry.token, entry.denomination, entry.note)
else if (entry.invoice)
return new Docs.Invoice(++id, entry.network, entry.token, entry.denomination, entry.invoice)
}) as Array<T>
)
.catch((err) => {
throw ErrorUtils.ensureError(err)
})
// TODO: Decide whether to close caches by default or not
//await cache.close().catch((err) => {
// throw ErrorUtils.ensureError(err)
//})
}
loadDepositCache(name: string, options?: ChainOptions.Sync): DepositCache {
if (!this.caches.has(name)) {
this.caches.set(
name,
new DepositCache(
name,
options ? { adapter: options?.cacheAdapter, persistent: options?.persistentCache } : undefined
)
)
}
return this.caches.get(name) as DepositCache
}
loadWithdrawalCache(name: string, options?: ChainOptions.Sync): WithdrawalCache {
if (!this.caches.has(name)) {
this.caches.set(
name,
new WithdrawalCache(
name,
options ? { adapter: options?.cacheAdapter, persistent: options?.persistentCache } : undefined
)
)
}
return this.caches.get(name) as WithdrawalCache
}
loadCache<C extends Cache.Base<Docs.Base>>(name: string, options?: ChainOptions.Sync): C {
if (!this.caches.has(name)) {
this.caches.set(
name,
new Cache.Base(
name,
options ? { adapter: options?.cacheAdapter, persistent: options?.persistentCache } : undefined
)
)
}
return this.caches.get(name) as C
}
async syncDeposits(instance: TornadoInstance, options?: ChainOptions.Sync): Promise<void> {
const lookupKeys = await Onchain.getInstanceLookupKeys(instance.address)
const pathstring = lookupKeys.network + lookupKeys.token + lookupKeys.denomination
options = options ?? {}
options.startBlock = await Onchain.getInstanceDeployBlockNum(
lookupKeys.network,
lookupKeys.token,
lookupKeys.denomination
)
const populatedOptions = await this._populateSyncOptions(options)
const cache = this.loadDepositCache('Deposits' + pathstring.toUpperCase(), populatedOptions)
await this.sync(instance.filters.Deposit(null, null, null), instance, cache, populatedOptions)
if (!this.instances.has(pathstring)) this.instances.set(pathstring, instance)
if (!this.caches.has(cache.name)) this.caches.set(cache.name, cache)
}
async syncWithdrawals(instance: TornadoInstance, options?: ChainOptions.Sync): Promise<void> {
const lookupKeys = await Onchain.getInstanceLookupKeys(instance.address)
const pathstring = lookupKeys.network + lookupKeys.token + lookupKeys.denomination
options = options ?? {}
options.startBlock = await Onchain.getInstanceDeployBlockNum(
lookupKeys.network,
lookupKeys.token,
lookupKeys.denomination
)
const populatedOptions = await this._populateSyncOptions(options)
const cache = this.loadWithdrawalCache('Withdrawals' + pathstring.toUpperCase(), populatedOptions)
await this.sync(instance.filters.Withdrawal(null, null, null), instance, cache, populatedOptions)
if (!this.instances.has(pathstring)) this.instances.set(pathstring, instance)
if (!this.caches.has(cache.name)) this.caches.set(cache.name, cache)
}
protected async _populateSyncOptions(options: ChainOptions.Sync): Promise<DeepRequired<ChainOptions.Sync>> {
if (!options.startBlock) throw ErrorUtils.getError('Core._populateSyncOptions: startBlock not set.')
options.targetBlock = options.targetBlock ?? (await this.chain.latestBlockNum())
options.blockDivisor = options.blockDivisor ?? 40
options.blockDelta = Math.floor((options.targetBlock - options.startBlock) / options.blockDivisor)
options.concurrencyLimit = options.concurrencyLimit ?? 8
options.msTimeout = options.msTimeout ?? 200 // 5 requests per second
options.persistentCache = options.persistentCache ?? true
options.cacheAdapter = options.cacheAdapter ?? 'leveldb'
options.listenForEvents = options.listenForEvents ?? false
return options as DeepRequired<ChainOptions.Sync>
}
}
export { Transactions, Options }