diff --git a/packages/sessions/src/tracker.ts b/packages/sessions/src/tracker.ts index 2ba337339..f076943fe 100644 --- a/packages/sessions/src/tracker.ts +++ b/packages/sessions/src/tracker.ts @@ -18,7 +18,7 @@ export type ConfigDataDump = { presignedTransactions: PresignedConfigLink[] } -export abstract class ConfigTracker { +export interface ConfigTracker { loadPresignedConfiguration: (args: { wallet: string fromImageHash: string diff --git a/packages/sessions/src/trackers/arweave.ts b/packages/sessions/src/trackers/arweave.ts new file mode 100644 index 000000000..143d80265 --- /dev/null +++ b/packages/sessions/src/trackers/arweave.ts @@ -0,0 +1,573 @@ +import { commons, v2 } from '@0xsequence/core' +import { migrator } from '@0xsequence/migration' +import { CachedEIP5719 } from '@0xsequence/replacer' +import { ethers } from 'ethers' +import { ConfigTracker, PresignedConfig, PresignedConfigLink } from '../tracker' + +const RATE_LIMIT_RETRY_DELAY = 5 * 60 * 1000 + +// depending on @0xsequence/abi breaks 0xsequence's proxy-transport-channel integration test +const MAIN_MODULE_ABI = [ + ` + function execute( + ( + bool delegateCall, + bool revertOnError, + uint256 gasLimit, + address target, + uint256 value, + bytes data + )[] calldata transactions, + uint256 nonce, + bytes calldata signature + ) + ` +] + +export class ArweaveReader implements ConfigTracker, migrator.PresignedMigrationTracker { + private readonly configs: Map> = new Map() + + private readonly eip5719?: CachedEIP5719 + + constructor( + readonly namespace = 'Sequence-Sessions', + eip5719Provider?: ethers.providers.Provider + ) { + if (eip5719Provider) { + this.eip5719 = new CachedEIP5719(eip5719Provider) + } + } + + async loadPresignedConfiguration(args: { + wallet: string + fromImageHash: string + longestPath?: boolean + }): Promise { + const wallet = ethers.utils.getAddress(args.wallet) + + const fromConfig = await this.configOfImageHash({ imageHash: args.fromImageHash }) + if (!fromConfig) { + throw new Error(`unable to find from config ${args.fromImageHash}`) + } + if (!v2.config.isWalletConfig(fromConfig)) { + throw new Error(`from config ${args.fromImageHash} is not v2`) + } + const fromCheckpoint = ethers.BigNumber.from(fromConfig.checkpoint) + + const items = Object.entries( + await findItems({ Type: 'config update', Wallet: wallet }, { namespace: this.namespace }) + ).flatMap(([id, tags]) => { + try { + const { Signer: signer, Subdigest: subdigest, Digest: digest, 'To-Config': toImageHash } = tags + + let toCheckpoint: ethers.BigNumber + try { + toCheckpoint = ethers.BigNumber.from(tags['To-Checkpoint']) + } catch { + throw new Error(`to checkpoint is not a number: ${tags['To-Checkpoint']}`) + } + if (toCheckpoint.lte(fromCheckpoint)) { + return [] + } + + if (!ethers.utils.isAddress(signer)) { + throw new Error(`signer is not an address: ${signer}`) + } + + if (!ethers.utils.isHexString(subdigest, 32)) { + throw new Error(`subdigest is not a hash: ${subdigest}`) + } + + if (!ethers.utils.isHexString(digest, 32)) { + throw new Error(`digest is not a hash: ${digest}`) + } + + let chainId: ethers.BigNumber + try { + chainId = ethers.BigNumber.from(tags['Chain-ID']) + } catch { + throw new Error(`chain id is not a number: ${tags['Chain-ID']}`) + } + + if (!ethers.utils.isHexString(toImageHash, 32)) { + throw new Error(`to config is not a hash: ${toImageHash}`) + } + + return [{ id, signer, subdigest, digest, chainId, toImageHash, toCheckpoint }] + } catch (error) { + console.warn(`invalid wallet ${wallet} config update ${id}:`, error) + return [] + } + }) + + const signatures: Map> = new Map() + let candidates: typeof items = [] + + for (const item of items) { + let imageHashSignatures = signatures.get(item.toImageHash) + if (!imageHashSignatures) { + imageHashSignatures = new Map() + signatures.set(item.toImageHash, imageHashSignatures) + candidates.push(item) + } + imageHashSignatures.set(item.signer, item) + } + + if (args.longestPath) { + candidates.sort(({ toCheckpoint: a }, { toCheckpoint: b }) => a.sub(b).toNumber()) + } else { + candidates.sort(({ toCheckpoint: a }, { toCheckpoint: b }) => b.sub(a).toNumber()) + } + + const updates: PresignedConfigLink[] = [] + + for (let currentConfig = fromConfig; candidates.length; ) { + const currentImageHash = v2.config.imageHash(currentConfig) + + let nextCandidate: (typeof candidates)[number] | undefined + let nextCandidateItems: Map + let nextCandidateSigners: string[] = [] + + for (const candidate of candidates) { + nextCandidateItems = signatures.get(candidate.toImageHash)! + nextCandidateSigners = Array.from(nextCandidateItems.keys()) + + const { weight } = v2.signature.encodeSigners( + currentConfig, + new Map(nextCandidateSigners.map(signer => [signer, { signature: '0x', isDynamic: false }])), + [], + 0 + ) + + if (weight.gte(currentConfig.threshold)) { + nextCandidate = candidate + break + } + } + + if (!nextCandidate) { + console.warn( + `unreachable configs with checkpoint > ${ethers.BigNumber.from(currentConfig.checkpoint).toString()} from config ${currentImageHash}` + ) + break + } + + const nextImageHash = nextCandidate.toImageHash + const nextConfig = await this.configOfImageHash({ imageHash: nextImageHash }) + if (!nextConfig) { + console.warn(`unable to find config ${nextImageHash}`) + candidates = candidates.filter(({ toImageHash }) => toImageHash !== nextImageHash) + continue + } + if (!v2.config.isWalletConfig(nextConfig)) { + console.warn(`config ${nextImageHash} is not v2`) + candidates = candidates.filter(({ toImageHash }) => toImageHash !== nextImageHash) + continue + } + + try { + const nextCandidateSignatures = new Map( + ( + await Promise.all( + nextCandidateSigners.map(async signer => { + const { id, subdigest } = nextCandidateItems.get(signer)! + try { + let signature = await (await fetchItem(id)).text() + if (this.eip5719) { + try { + signature = ethers.utils.hexlify(await this.eip5719.runByEIP5719(signer, subdigest, signature)) + } catch (error) { + console.warn(`unable to run eip-5719 on config update ${id}`) + } + } + const recovered = commons.signer.tryRecoverSigner(subdigest, signature) + return [[signer, { signature, isDynamic: recovered !== signer }] as const] + } catch (error) { + console.warn(`unable to fetch signer ${signer} config update ${id}:`, error) + return [] + } + }) + ) + ).flat() + ) + + const { encoded: signature, weight } = v2.signature.encodeSigners(currentConfig, nextCandidateSignatures, [], 0) + if (weight.lt(currentConfig.threshold)) { + throw new Error( + `insufficient signing power ${weight.toString()} < ${ethers.BigNumber.from(currentConfig.threshold).toString()}` + ) + } + updates.push({ wallet, signature, nextImageHash }) + + currentConfig = nextConfig + candidates = candidates.filter(({ toCheckpoint }) => toCheckpoint.gt(currentConfig.checkpoint)) + } catch (error) { + console.warn( + `unable to reconstruct wallet ${wallet} update from config ${currentImageHash} to config ${nextImageHash}:`, + error + ) + candidates = candidates.filter(({ toImageHash }) => toImageHash !== nextImageHash) + } + } + + return updates + } + + savePresignedConfiguration(_args: PresignedConfig): Promise { + throw new Error('arweave backend does not support saving config updates') + } + + saveWitnesses(_args: { wallet: string; digest: string; chainId: ethers.BigNumberish; signatures: string[] }): Promise { + throw new Error('arweave backend does not support saving signatures') + } + + async configOfImageHash(args: { imageHash: string; noCache?: boolean }): Promise { + if (!args.noCache) { + const config = this.configs.get(args.imageHash) + if (config) { + try { + return await config + } catch { + const config = this.configs.get(args.imageHash) + if (config) { + return config + } + } + } + } + + const config = (async (imageHash: string): Promise => { + const items = Object.entries(await findItems({ Type: 'config', Config: imageHash }, { namespace: this.namespace })).flatMap( + ([id, tags]) => { + try { + const version = Number(tags.Version) + if (!version) { + throw new Error(`invalid version: ${tags.Version}`) + } + + return [{ id, version }] + } catch (error) { + console.warn(`config ${imageHash} at ${id} invalid:`, error) + return [] + } + } + ) + + switch (items.length) { + case 0: + this.configs.set(imageHash, Promise.resolve(undefined)) + return + case 1: + break + default: + console.warn(`multiple configs ${imageHash} at ${items.map(({ id }) => id).join(', ')}, using first`) + break + } + + const { id, version } = items[0] + + const config = { ...(await (await fetchItem(id)).json()), version } + if (config.tree) { + config.tree = toTopology(config.tree) + } + this.configs.set(imageHash, Promise.resolve(config)) + return config + })(args.imageHash) + + if (!args.noCache) { + this.configs.set(args.imageHash, config) + } + + return config + } + + saveWalletConfig(_args: { config: commons.config.Config }): Promise { + throw new Error('arweave backend does not support saving configs') + } + + async imageHashOfCounterfactualWallet(args: { + wallet: string + noCache?: boolean + }): Promise<{ imageHash: string; context: commons.context.WalletContext } | undefined> { + const wallet = ethers.utils.getAddress(args.wallet) + + const items = Object.entries(await findItems({ Type: 'wallet', Wallet: wallet }, { namespace: this.namespace })).flatMap( + ([id, tags]) => { + try { + const { 'Deploy-Config': imageHash } = tags + + const version = Number(tags['Deploy-Version']) + if (!version) { + throw new Error(`invalid version: ${tags['Deploy-Version']}`) + } + + if (!imageHash) { + throw new Error('no deploy config') + } + + const context = commons.context.defaultContexts[version] + if (!context) { + throw new Error(`unknown version: ${version}`) + } + + if (commons.context.addressOf(context, imageHash) !== wallet) { + throw new Error(`incorrect v${version} deploy config: ${imageHash}`) + } + + return [{ id, imageHash, context }] + } catch (error) { + console.warn(`wallet ${wallet} at ${id} invalid:`, error) + return [] + } + } + ) + + switch (items.length) { + case 0: + return + case 1: + break + default: + console.warn(`multiple deploy configs for wallet ${wallet} at ${items.map(({ id }) => id).join(', ')}, using first`) + break + } + + return items[0] + } + + saveCounterfactualWallet(_args: { config: commons.config.Config; context: commons.context.WalletContext[] }): Promise { + throw new Error('arweave backend does not support saving wallets') + } + + async walletsOfSigner(args: { + signer: string + noCache?: boolean + }): Promise> { + const signer = ethers.utils.getAddress(args.signer) + + const proofs: Map }> = new Map() + + for (const [id, tags] of Object.entries( + await findItems({ Type: ['signature', 'config update'], Signer: signer, Witness: 'true' }, { namespace: this.namespace }) + )) { + const { Wallet: wallet, Subdigest: subdigest, Digest: digest, 'Chain-ID': chainId } = tags + + try { + if (proofs.has(wallet)) { + continue + } + + if (subdigest !== commons.signature.subdigestOf({ digest, chainId, address: wallet })) { + throw new Error('incorrect subdigest') + } + + proofs.set(wallet, { + digest, + chainId: ethers.BigNumber.from(chainId), + signature: fetchItem(id).then(response => response.text()) + }) + } catch (error) { + console.warn(`signer ${signer} signature ${id} of wallet ${wallet} invalid:`, error) + } + } + + return Promise.all( + [...proofs.entries()].map(async ([wallet, { digest, chainId, signature }]) => ({ + wallet, + proof: { digest, chainId, signature: await signature } + })) + ) + } + + async getMigration( + address: string, + fromImageHash: string, + fromVersion: number, + chainId: ethers.BigNumberish + ): Promise { + const wallet = ethers.utils.getAddress(address) + + const items = Object.entries( + await findItems( + { + Type: 'migration', + Migration: wallet, + 'Chain-ID': ethers.BigNumber.from(chainId).toString(), + 'From-Version': `${fromVersion}`, + 'From-Config': fromImageHash + }, + { namespace: this.namespace } + ) + ).flatMap(([id, tags]) => { + try { + const { 'To-Config': toImageHash, Executor: executor } = tags + + const toVersion = Number(tags['To-Version']) + if (!toVersion) { + throw new Error(`invalid version: ${tags['To-Version']}`) + } + + if (!ethers.utils.isHexString(toImageHash, 32)) { + throw new Error(`to config is not a hash: ${toImageHash}`) + } + + if (!ethers.utils.isAddress(executor)) { + throw new Error(`executor is not an address: ${executor}`) + } + + return { id, toVersion, toImageHash, executor } + } catch (error) { + console.warn( + `chain ${ethers.BigNumber.from(chainId).toString()} migration ${id} for v${fromVersion} wallet ${wallet} from config ${fromImageHash} invalid:`, + error + ) + return [] + } + }) + + switch (items.length) { + case 0: + return + case 1: + break + default: + console.warn( + `multiple chain ${chainId} migrations for v${fromVersion} wallet ${wallet} from config ${fromImageHash} at ${items.map(({ id }) => id).join(', ')}, using first` + ) + break + } + + const { id, toVersion, toImageHash, executor } = items[0] + + const [data, toConfig] = await Promise.all([ + fetchItem(id).then(response => response.text()), + this.configOfImageHash({ imageHash: toImageHash }) + ]) + + if (!toConfig) { + throw new Error(`unable to find to config ${toImageHash} for migration`) + } + + const mainModule = new ethers.utils.Interface(MAIN_MODULE_ABI) + const [encoded, nonce, signature] = mainModule.decodeFunctionData('execute', data) + const transactions = commons.transaction.fromTxAbiEncode(encoded) + const subdigest = commons.transaction.subdigestOfTransactions(wallet, chainId, nonce, transactions) + + return { + tx: { entrypoint: executor, transactions, nonce, chainId, intent: { id: subdigest, wallet }, signature }, + fromVersion, + toVersion: Number(toVersion), + toConfig + } + } + + saveMigration(_address: string, _signed: migrator.SignedMigration, _contexts: commons.context.VersionedContext): Promise { + throw new Error('arweave backend does not support saving migrations') + } +} + +async function findItems( + filter: { [name: string]: string | string[] }, + options?: { namespace?: string; pageSize?: number; maxResults?: number } +): Promise<{ [id: string]: { [tag: string]: string } }> { + const namespace = options?.namespace + const pageSize = options?.pageSize ?? 100 + const maxResults = options?.maxResults + + const tags = Object.entries(filter).map( + ([name, values]) => + `{ name: "${namespace ? `${namespace}-${name}` : name}", values: [${typeof values === 'string' ? `"${values}"` : values.map(value => `"${value}"`).join(', ')}] }` + ) + + const edges: Array<{ cursor: string; node: { id: string; tags: Array<{ name: string; value: string }> } }> = [] + + for (let hasNextPage = true; hasNextPage && (maxResults === undefined || edges.length < maxResults); ) { + const query = ` + query { + transactions(sort: HEIGHT_DESC, ${edges.length ? `first: ${pageSize}, after: "${edges[edges.length - 1].cursor}"` : `first: ${pageSize}`}, tags: [${tags.join(', ')}]) { + pageInfo { + hasNextPage + } + edges { + cursor + node { + id + tags { + name + value + } + } + } + } + } + ` + + let response: Response + while (true) { + response = await fetch('https://arweave.net/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + redirect: 'follow' + }) + if (response.status !== 429) { + break + } + await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_RETRY_DELAY)) + } + + const { + data: { transactions } + } = await response.json() + + edges.push(...transactions.edges) + + hasNextPage = transactions.pageInfo.hasNextPage + } + + return Object.fromEntries( + edges.map(({ node: { id, tags } }) => [ + id, + Object.fromEntries( + tags.map(({ name, value }) => [ + namespace && name.startsWith(`${namespace}-`) ? name.slice(namespace.length + 1) : name, + value + ]) + ) + ]) + ) +} + +async function fetchItem(id: string): Promise { + while (true) { + const response = await fetch(`https://arweave.net/${id}`, { redirect: 'follow' }) + if (response.status !== 429) { + return response + } + await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_RETRY_DELAY)) + } +} + +function toTopology(topology: any): v2.config.Topology { + if (typeof topology === 'string') { + return { nodeHash: topology } + } + + if (typeof topology === 'object' && topology?.node !== undefined) { + return { nodeHash: topology.node } + } + + if (topology instanceof Array && topology.length === 2) { + return { left: toTopology(topology[0]), right: toTopology(topology[1]) } + } + + if (v2.config.isNode(topology)) { + return { left: toTopology(topology.left), right: toTopology(topology.right) } + } + + if (v2.config.isNestedLeaf(topology)) { + return { ...topology, tree: toTopology(topology.tree) } + } + + return topology +} diff --git a/packages/sessions/src/trackers/index.ts b/packages/sessions/src/trackers/index.ts index 05dddeb00..a26c0c789 100644 --- a/packages/sessions/src/trackers/index.ts +++ b/packages/sessions/src/trackers/index.ts @@ -1,3 +1,4 @@ +export * as arweave from './arweave' export * as debug from './debug' export * as local from './local' export * as remote from './remote' diff --git a/packages/sessions/tests/arweave.spec.ts b/packages/sessions/tests/arweave.spec.ts new file mode 100644 index 000000000..02363d906 --- /dev/null +++ b/packages/sessions/tests/arweave.spec.ts @@ -0,0 +1,127 @@ +import { commons, universal } from '@0xsequence/core' +import { expect } from 'chai' +import { ethers } from 'ethers' + +import { trackers } from '../src' + +describe('Arweave config reader', () => { + const namespace = 'axovybcmguutleij' + + it('Should find the config for an image hash', async () => { + const imageHash = '0x8f482f815eaa9520202b76568b8603defad3460f9f345b2bf87a28df5b5cb3db' + + const reader = new trackers.arweave.ArweaveReader(namespace) + const config = await reader.configOfImageHash({ imageHash }) + if (!config) { + throw new Error('config not found') + } + + const coder = universal.genericCoderFor(config.version) + expect(coder.config.imageHashOf(config)).to.equal(imageHash) + }) + + it('Should find the deploy config for a wallet', async () => { + const address = '0xF67736062872Dbc10FD2882B15C868b6c9645A9D' + + const reader = new trackers.arweave.ArweaveReader(namespace) + const wallet = await reader.imageHashOfCounterfactualWallet({ wallet: address }) + if (!wallet) { + throw new Error('wallet not found') + } + + expect(commons.context.addressOf(wallet.context, wallet.imageHash)).to.equal(address) + }) + + it('Should find the wallets for a signer', async () => { + const signer = '0x764d3a80ae2C1Dc0a38d14787f382168EF0Cd270' + + const reader = new trackers.arweave.ArweaveReader(namespace) + const wallets = await reader.walletsOfSigner({ signer }) + + expect(wallets.some(({ wallet }) => wallet === '0x647E7eb8E2834f8818E964B97336e41E20639267')).to.be.true + + expect( + wallets.every( + ({ wallet, proof: { digest, chainId, signature } }) => + commons.signer.recoverSigner(commons.signature.subdigestOf({ digest, chainId, address: wallet }), signature) === signer + ) + ).to.be.true + }) + + it('Should find the shortest sequence of config updates from a config', async () => { + const wallet = '0x05971669C685c1ECbc4D441D1b81Ecc49A249EEe' + const fromImageHash = '0x004c53ffce56402f25764c11c5538f83b73064cc2bd15b14701062f92fd3d648' + + const reader = new trackers.arweave.ArweaveReader(namespace) + const updates = await reader.loadPresignedConfiguration({ wallet, fromImageHash }) + + expect(updates).to.deep.equal([ + { + wallet: '0x05971669C685c1ECbc4D441D1b81Ecc49A249EEe', + nextImageHash: '0x0120af3a0e2941d5a36b7f2e243610f6351a8e290da1bec3cbc3b6b779222884', + signature: + '0x020005000000000003d5201fd0e49c26d0cade41946fb556027b2dff5bcfabaccade08966202848e7e2a176606a431262902c871978e2f04366f02da9b82d91b8c4fcaaa6e14ddfeee1b02040000160102597772c0a183204efaec323b37a9ed6d88c988040400007b02031d76d1d72ec65a9b933745bd0a87caa0fac75af0000062020001000000000001b55725759bf1af93aab1669a44e2c0f1bf1c04103c1a3c2b81fc29fe54bcc49f1342954985f438410c8c8aa3d049675886bbd8b52e256e1cb9d7c10a616f8d901c02010190d62a32d1cc65aa3e80b567c8c0d3ca0f411e6103' + } + ]) + }) + + it('Should find the longest sequence of config updates from a config', async () => { + const wallet = '0x05971669C685c1ECbc4D441D1b81Ecc49A249EEe' + const fromImageHash = '0x004c53ffce56402f25764c11c5538f83b73064cc2bd15b14701062f92fd3d648' + + const reader = new trackers.arweave.ArweaveReader(namespace) + const updates = await reader.loadPresignedConfiguration({ wallet, fromImageHash, longestPath: true }) + + expect(updates).to.deep.equal([ + { + wallet: '0x05971669C685c1ECbc4D441D1b81Ecc49A249EEe', + nextImageHash: '0x8aebdaf8de8d8c6db6879841e7b85f6480841282af739a9f39b1f0c69b42d6a2', + signature: + '0x0200050000000000038ee84d1cf3a3bd92165f0b85f83e407a7e69a3ee76d82f214e189b5aa5cf2a05277bd8e0723d4b027125058cfa2c6e7eba6c68051ee95368cf80245e1d7ebbb81b02040000160102597772c0a183204efaec323b37a9ed6d88c988040400007b02031d76d1d72ec65a9b933745bd0a87caa0fac75af0000062020001000000000001ac512777ebd109baf295b2f20d4ba11ef847644dda15a6b19eefd857b195a0294401a3a0080f529fda753191660ab61588356c9bf15b8cd54117fc0cf0f6c8fe1c02010190d62a32d1cc65aa3e80b567c8c0d3ca0f411e6103' + }, + { + wallet: '0x05971669C685c1ECbc4D441D1b81Ecc49A249EEe', + nextImageHash: '0x37758fed5c4c60994461125152139963b5025521cbd7c708de3d95df396605e0', + signature: + '0x0200050000000103c4b8aa34ceeec966dd15d51d5004c2695e21efc746dcb48531e8670ed01b858e0400007b02031d76d1d72ec65a9b933745bd0a87caa0fac75af0000062020001000000000001ee79a0d368eb32bc73446fabbf65e265beadc06d41720bb5144852e6182dff92154ebec93a0b0f28344dcf4fc533b979d2612e73c8cf04f0aac653e3014394c91b02010190d62a32d1cc65aa3e80b567c8c0d3ca0f411e6103040000440002c8989589a1489d456908c6c2f0317ce0bacf8fc2d64a696461642cfdb6d439f725d19274ce07f96f401232413fa0f9ce6d98497d20935138c2b710fa7ae9f5e01b02' + }, + { + wallet: '0x05971669C685c1ECbc4D441D1b81Ecc49A249EEe', + nextImageHash: '0x0120af3a0e2941d5a36b7f2e243610f6351a8e290da1bec3cbc3b6b779222884', + signature: + '0x020005000000020003d5201fd0e49c26d0cade41946fb556027b2dff5bcfabaccade08966202848e7e2a176606a431262902c871978e2f04366f02da9b82d91b8c4fcaaa6e14ddfeee1b02040000160102c623539534a553bb81a8e85698e5103bb55f2dac0400007b02031d76d1d72ec65a9b933745bd0a87caa0fac75af0000062020001000000000001b55725759bf1af93aab1669a44e2c0f1bf1c04103c1a3c2b81fc29fe54bcc49f1342954985f438410c8c8aa3d049675886bbd8b52e256e1cb9d7c10a616f8d901c02010190d62a32d1cc65aa3e80b567c8c0d3ca0f411e6103' + } + ]) + }) + + it('Should find a migration', async () => { + const address = '0x32284cD48A2cD2b3613Cbf8CD56693fe39B738Ee' + const fromVersion = 1 + const fromImageHash = '0x2662c159baa712737224f8a3aef97e5585ba4f2550ad2354832066b88b44fddf' + const toVersion = 2 + const toImageHash = '0xd2a9ad2da5358d21878a6e79d39feb4c1e67f984aa3db074021b51b6ffdad3d5' + const chainId = 42161 + + const reader = new trackers.arweave.ArweaveReader(namespace) + const migration = await reader.getMigration(address, fromImageHash, fromVersion, chainId) + if (!migration) { + throw new Error('migration not found') + } + + expect(migration.tx.intent.wallet).to.equal(address) + expect(ethers.BigNumber.from(migration.tx.chainId).eq(chainId)).to.be.true + expect(migration.fromVersion).to.equal(fromVersion) + expect(migration.toVersion).to.equal(toVersion) + expect(migration.toConfig.version).to.equal(toVersion) + + const toCoder = universal.genericCoderFor(migration.toVersion) + expect(toCoder.config.imageHashOf(migration.toConfig)).to.equal(toImageHash) + + const provider: ethers.providers.Provider = null! + const fromCoder = universal.genericCoderFor(migration.fromVersion) + const decoded = fromCoder.signature.decode(migration.tx.signature) + const digest = commons.transaction.digestOfTransactions(migration.tx.nonce, migration.tx.transactions) + const recovered = await fromCoder.signature.recover(decoded, { digest, chainId, address }, provider) + expect(fromCoder.config.imageHashOf(recovered.config)).to.equal(fromImageHash) + }) +})