sdk-monorepo/src/lib/main.ts

538 lines
20 KiB
TypeScript

// ts-essentials
import { DeepRequired } from 'ts-essentials'
// Local types
import { RelayerProperties } from 'types/sdk/data'
import { Options, Transactions } from 'types/sdk/main'
import { ZKDepositData, InputFor } from 'types/sdk/crypto'
import { TornadoInstance, TornadoProxy } from 'types/deth'
// External imports
import { BigNumber, EventFilter, providers } from 'ethers'
// @ts-ignore
import { parseIndexableString } from 'pouchdb-collate'
// Important local
import { Docs, Cache, Types as DataTypes, Json, Constants } from 'lib/data'
import { Primitives } from 'lib/crypto'
import { Contracts } from 'lib/chain'
// Other local imports
import { OnchainData } from 'lib/data'
import { ErrorUtils } from 'lib/utils'
import { Chain } from 'lib/chain'
import { parseUnits } from 'ethers/lib/utils'
type Provider = providers.Provider
type BackupDepositDoc = {
pathstring: string
invoice?: string
note?: string
}
export class Core {
chain: Chain
caches: Map<string, Cache.Base<Docs.Base>>
instances: Map<string, TornadoInstance>
constructor(provider: providers.Provider) {
this.chain = new Chain(provider)
this.caches = new Map<string, Cache.Base<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 = await this.chain.getChainId()
return Contracts.getInstance(String(chainId), token, String(denomination), this.chain.provider)
}
async getProxy(): Promise<TornadoProxy> {
const chainId = await this.chain.getChainId()
return Contracts.getProxy(String(chainId), this.chain.signer ?? this.chain.provider)
}
async buildDepositProof(
instance: TornadoInstance,
relayerProperties: RelayerProperties,
recipientAddress: string,
zkDepositsData: ZKDepositData,
options?: Options.Core.BuildDepositProof
): Promise<any> {
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. 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 Additional options which allow the user to skip checking whether the notes are spent or changing the target merkle tree height.
* @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<any>> {
// Extract commitments and nullifier hashes
const hexCommitments: string[] = []
const hexNullifierHashes: string[] = []
const purchaseAmounts = options?.ethPurchaseAmounts ?? new Array(zkDepositsData.length)
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 this.getInstanceLookupKeys(instance.address)
const name = 'Deposit' + (lookupKeys.network + lookupKeys.token + lookupKeys.denomination).toUpperCase()
// Find all leaf indices by reading from cache
const leafIndices = await this._findLeafIndices(name, hexCommitments)
const invalidCommitments: string[] = []
// Determine whether we will be checking whether notes are spent
const spentNotes: string[] = []
const checkSpent = options?.checkNotesSpent !== false
// If yes, immediately check it with the supplied Tornado Instance
const checkSpentArray = checkSpent ? await instance.isSpentArray(hexNullifierHashes) : null
// 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.buildWithdrawalTxs: ` +
(commitmentsAreInvalid
? `following commitments are invalid:\n\n${invalidCommitments.join('\n')}\n\n`
: '') +
(notesAreSpent
? `${commitmentsAreInvalid ? 'and ' : ''}following notes are already spent:\n\n${spentNotes.join(
'\n'
)}\n\n`
: '')
)
// Otherwise, build the merkle tree from the leaf indices
// We have to slice to get the leaf indices in order
const merkleTree = Primitives.buildMerkleTree({
height: options?.merkleTreeHeight ?? Constants.MERKLE_TREE_HEIGHT,
leaves: leafIndices.slice(zkDepositsData.length).map((leafIndex) => String(leafIndex))
})
const root: string = merkleTree.root()
// Check whether the root is valid
if (!(await instance.isKnownRoot(root)))
throw ErrorUtils.getError(
'Core.buildWithdrawalTxs: the merkle tree created is not valid, something went wrong with syncing.'
)
// Compute proofs
const inputsForProofs: InputFor.ZKProof[] = []
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: 5, // TODO: placeholder
refund: purchaseAmounts[i] ?? 0
},
private: {
nullifier: zkDepositsData[i].nullifier,
secret: zkDepositsData[i].secret
}
})
}
return await Primitives.calcDepositProofs(inputsForProofs)
}
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.buildDepositTxs(instances, options)
}
async buildDepositTx(
instance: TornadoInstance,
options?: Options.Core.Deposit
): Promise<Transactions.Deposit> {
let opts: Options.Core.Deposit = options ?? {}
opts.depositsPerInstance = [1]
return (await this.buildDepositTxs([instance], opts))[0]
}
async buildDepositTxs(
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 this.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({ pathstring: pathstring, note: note })
if (backupInvoices) invoicesToBackup.push({ pathstring: pathstring, 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(new Cache.Base<Docs.Note>('DepositNotes'), notesToBackup)
if (backupInvoices)
await this._backupDepositData(new 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.pathstring, entry.note)
else if (entry.invoice) return new Docs.Invoice(++id, entry.pathstring, entry.invoice)
}) as Array<T>
)
.catch((err) => {
throw ErrorUtils.ensureError(err)
})
await cache.close().catch((err) => {
throw ErrorUtils.ensureError(err)
})
}
async syncMultiple(instances: Array<TornadoInstance>, syncOptions?: Options.Core.Sync): Promise<void> {
for (const instance of instances) {
await this.sync(instance, syncOptions)
}
}
async sync(instance: TornadoInstance, syncOptions?: Options.Core.Sync): Promise<void> {
// Get some data
const lookupKeys = await this.getInstanceLookupKeys(instance.address)
const populatedSyncOpts = await this._populateSyncOpts(lookupKeys, syncOptions)
const actions = Object.entries(populatedSyncOpts).filter((el) => el[1] === true) as [string, boolean][]
// Synchronize
for (let i = 0, bound = actions.length; i < bound; i++) {
let action = actions[i][0].charAt(0).toUpperCase() + actions[i][0].slice(1)
await this._sync(action, lookupKeys, instance, populatedSyncOpts)
}
}
private async _sync(
action: string,
lookupKeys: DataTypes.Keys.InstanceLookup,
instance: TornadoInstance,
syncOptions: DeepRequired<Options.Core.Sync>
): Promise<void> {
const name = `${action + 's'}${lookupKeys.network}${lookupKeys.token.toUpperCase()}${
lookupKeys.denomination
}`,
pathstring = name.substring(action.length).toLowerCase()
let cache: Cache.Syncable<Docs.Base>, toDoc: (_: any) => Docs.Base, filter: EventFilter
if (action == 'Deposit') {
toDoc = (resp: any) => new Docs.Deposit(resp)
cache = new Cache.Deposit(name, syncOptions.cache)
filter = instance.filters.Deposit(null, null, null)
} else {
toDoc = (resp: any) => new Docs.Withdrawal(resp)
cache = new Cache.Withdrawal(name, syncOptions.cache)
filter = instance.filters.Withdrawal(null, null, null, null)
}
// Assign pooler
cache.sync.pooler = await cache.sync.initializePooler(cache.getCallbacks(instance))
// Start synchronizing
let dbPromises = []
for (
let currentBlock = syncOptions.blocks.startBlock,
blockDelta = syncOptions.blocks.blockDelta,
targetBlock = syncOptions.blocks.targetBlock,
concurrencyLimit = syncOptions.cache.sync.concurrencyLimit;
currentBlock < targetBlock + blockDelta;
currentBlock += blockDelta
) {
if (cache.sync.pooler.pending < concurrencyLimit) {
if (currentBlock < targetBlock) {
await cache.sync.pooler.pool(currentBlock, currentBlock + blockDelta)
} else {
let sum = currentBlock + blockDelta
await cache.sync.pooler.pool(currentBlock, sum - (sum % targetBlock))
}
} else {
let res: Array<any> = await cache.sync.pooler.race()
if (res.length != 0)
dbPromises.push(
cache.db.bulkDocs(res.map((el) => toDoc(el))).catch((err) => {
throw ErrorUtils.ensureError(err)
})
)
currentBlock -= blockDelta
}
}
// Immediately start listening if we're doing this
if (syncOptions.cache.sync.listen)
instance = instance.on(filter, (...eventArgs) => {
cache.db.put(toDoc(eventArgs[eventArgs.length - 1]))
})
// Then wait for all pooler requests to resolve
let results = await cache.sync.pooler.all()
// Then transform them, we know the shape in forward
results = results.reduce((res: any[], response: any[]) => {
if (response[0]) response.forEach((el: any) => res.push(toDoc(el)))
return res
}, [])
// Then wait for old dbPromises to resolve
await Promise.all(dbPromises)
// Add the last docs
await cache.db.bulkDocs(results).catch((err) => {
throw ErrorUtils.ensureError(err)
})
// Finally, store the objects
if (!this.instances.has(pathstring)) this.instances.set(pathstring, instance)
if (!this.caches.has(name)) this.caches.set(name, cache)
}
private async _populateSyncOpts(
lookupKeys: DataTypes.Keys.InstanceLookup,
syncOptions?: Options.Core.Sync
): Promise<DeepRequired<Options.Core.Sync>> {
// Assign nonexistent
if (!syncOptions) syncOptions = {}
if (!syncOptions.blocks) syncOptions.blocks = {}
if (!syncOptions.cache) syncOptions.cache = { db: {}, sync: {} }
if (!syncOptions.cache.sync) syncOptions.cache.sync = {}
if (!syncOptions.cache.db) syncOptions.cache.db = {}
// Prepare options
// deposit & withdraw
const both = syncOptions.deposit === undefined && syncOptions.withdrawal === undefined
syncOptions.deposit = syncOptions.deposit ?? both
syncOptions.withdrawal = syncOptions.withdrawal ?? false
// blocks
syncOptions.blocks.startBlock =
syncOptions.blocks.startBlock ??
(await OnchainData.getInstanceDeployBlockNum(
lookupKeys.network,
lookupKeys.token,
lookupKeys.denomination
))
syncOptions.blocks.targetBlock = syncOptions.blocks.targetBlock ?? (await this.chain.latestBlockNum())
syncOptions.blocks.blockDelta =
syncOptions.blocks.blockDelta ?? (syncOptions.blocks.targetBlock - syncOptions.blocks.startBlock) / 20
// cache
// db
syncOptions.cache.db.persistent = syncOptions.cache.db.persistent ?? true
syncOptions.cache.db.adapter = syncOptions.cache.db.adapter ?? 'leveldb'
// sync
syncOptions.cache.sync.concurrencyLimit = syncOptions.cache.sync.concurrencyLimit ?? 8
syncOptions.cache.sync.listen = syncOptions.cache.sync.listen ?? false
return syncOptions as DeepRequired<Options.Core.Sync>
}
/**
* @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 _findLeafIndices(instanceName: string, commitments: Array<string>): Promise<Array<number>> {
const matchedLeafIndices = new Array<number>(commitments.length).fill(0)
const leafIndices: Array<number> = []
// Either load all deposit events from memory or from cache
let cache: Cache.Base<Docs.Deposit>
if (!this.caches.has(instanceName)) {
cache = new Cache.Base<Docs.Deposit>(instanceName)
} else cache = this.caches.get(instanceName) as Cache.Base<Docs.Deposit>
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) {
matchedLeafIndices[index] = leafIndex
commitments.splice(index, 1)
}
// In any case push every leaf
leafIndices.push(leafIndex)
}
// Concat matched and all leaf indices
return matchedLeafIndices.concat(leafIndices)
}
async getInstanceLookupKeys(instanceAddress: string): Promise<DataTypes.Keys.InstanceLookup> {
// lookup some stuff first
const lookupObj: { [key: string]: string } = Json.getValue(await Json.load('onchain/quickLookup.json'), [
'instanceAddresses'
])
const pathstring: string = Object.entries(lookupObj).find((el) => el[1] === instanceAddress)![0]
// I like JS/TS
const network = pathstring.match('[0-9]+')![0],
token = pathstring.substring(network.length).match('[a-z]+')![0],
denomination = pathstring.substring(network.length + token.length)
return {
network: network,
token: token,
denomination: denomination
}
}
}
export { Transactions, Options }