From fd5c86f0473c04507356d08a3d5cc57dce93f52c Mon Sep 17 00:00:00 2001 From: William Hua Date: Tue, 14 Apr 2026 14:42:16 -0400 Subject: [PATCH 01/12] core: arweave state reader --- .../wallet/core/src/state/arweave/arweave.ts | 113 +++ .../wallet/core/src/state/arweave/index.ts | 798 ++++++++++++++++++ .../wallet/core/src/state/arweave/schema.ts | 369 ++++++++ packages/wallet/core/src/state/index.ts | 7 +- .../core/test/state/arweave/arweave.test.ts | 120 +++ 5 files changed, 1404 insertions(+), 3 deletions(-) create mode 100644 packages/wallet/core/src/state/arweave/arweave.ts create mode 100644 packages/wallet/core/src/state/arweave/index.ts create mode 100644 packages/wallet/core/src/state/arweave/schema.ts create mode 100644 packages/wallet/core/test/state/arweave/arweave.test.ts diff --git a/packages/wallet/core/src/state/arweave/arweave.ts b/packages/wallet/core/src/state/arweave/arweave.ts new file mode 100644 index 0000000000..c3bb81f54a --- /dev/null +++ b/packages/wallet/core/src/state/arweave/arweave.ts @@ -0,0 +1,113 @@ +export interface Options { + readonly namespace?: string + readonly owners?: string[] + readonly arweaveUrl?: string + readonly graphqlUrl?: string + readonly rateLimitRetryDelayMs?: number +} + +export const defaults = { + namespace: 'Sequence-Sessions', + owners: ['AZ6R2mG8zxW9q7--iZXGrBknjegHoPzmG5IG-nxvMaM'], + arweaveUrl: 'https://arweave.net', + graphqlUrl: 'https://arweave.net/graphql', + rateLimitRetryDelayMs: 5 * 60 * 1000, +} + +export async function findItems( + filter: { [name: string]: undefined | string | string[] }, + options?: Options & { pageSize?: number; maxResults?: number }, +): Promise<{ [id: string]: { [tag: string]: string } }> { + const namespace = options?.namespace ?? defaults.namespace + const owners = options?.owners + const graphqlUrl = options?.graphqlUrl ?? defaults.graphqlUrl + const rateLimitRetryDelayMs = options?.rateLimitRetryDelayMs ?? defaults.rateLimitRetryDelayMs + const pageSize = options?.pageSize ?? 100 + const maxResults = options?.maxResults + + const tags = Object.entries(filter).flatMap(([name, values]) => + values === undefined + ? [] + : [ + `{ 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(', ')}]${owners === undefined ? '' : `, owners: [${owners.map((owner) => `"${owner}"`).join(', ')}]`}) { + pageInfo { + hasNextPage + } + edges { + cursor + node { + id + tags { + name + value + } + } + } + } + } + ` + + let response: Response + while (true) { + response = await fetch(graphqlUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + redirect: 'follow', + }) + if (response.status !== 429) { + break + } + console.warn( + `rate limited by ${graphqlUrl}, trying again in ${rateLimitRetryDelayMs / 1000} seconds at ${new Date(Date.now() + rateLimitRetryDelayMs).toLocaleTimeString()}`, + ) + await new Promise((resolve) => setTimeout(resolve, rateLimitRetryDelayMs)) + } + + 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, + ]), + ), + ]), + ) +} + +export async function fetchItem( + id: string, + rateLimitRetryDelayMs = defaults.rateLimitRetryDelayMs, + arweaveUrl = defaults.arweaveUrl, +): Promise { + while (true) { + const response = await fetch(`${arweaveUrl}/${id}`, { redirect: 'follow' }) + if (response.status !== 429) { + return response + } + console.warn( + `rate limited by ${arweaveUrl}, trying again in ${rateLimitRetryDelayMs / 1000} seconds at ${new Date(Date.now() + rateLimitRetryDelayMs).toLocaleTimeString()}`, + ) + await new Promise((resolve) => setTimeout(resolve, rateLimitRetryDelayMs)) + } +} diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts new file mode 100644 index 0000000000..5fa9538af0 --- /dev/null +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -0,0 +1,798 @@ +import { + Address as SequenceAddress, + Config, + Context, + GenericTree, + Payload, + Signature, +} from '@0xsequence/wallet-primitives' +import { Address, Bytes, Hex, Signature as OxSignature } from 'ox' +import { Reader as ReaderInterface, normalizeAddressKeys } from '../index.js' +import { defaults, fetchItem, findItems, Options } from './arweave.js' +import type { + ArweaveConfigRecord, + ArweaveObject, + ArweavePayloadRecord, + ArweaveSapientSignatureRecord, + ArweaveSignatureRecord, + ArweaveTreeRecord, + ArweaveV1ConfigUpdatePayloadRecord, + ArweaveV2ConfigUpdatePayloadRecord, + ArweaveV3ConfigUpdatePayloadRecord, + ArweaveWalletRecord, + CallData, + ConfigData, + SignatureType, + TreeData, + V1ConfigData, + V2ConfigTreeData, + V2ConfigData, + V3ConfigTreeData, + V3ConfigData, +} from './schema.js' + +type ArweaveConfigUpdatePayloadRecord = + | ArweaveV1ConfigUpdatePayloadRecord + | ArweaveV2ConfigUpdatePayloadRecord + | ArweaveV3ConfigUpdatePayloadRecord + +type ItemTags = { [tag: string]: string } +type ItemEntry = { id: string; tags: ItemTags } + +type Witness = { + chainId: number + payload: Payload.Parented + signature: TSignature +} + +type WitnessMap = { + [wallet: Address.Address]: Witness +} + +type Candidate = { + nextImageHash: Hex.Hex + checkpoint: bigint + signatureEntries: Map +} + +const PLAIN_SIGNATURE_TYPES = ['eip-712', 'eth_sign', 'erc-1271'] satisfies SignatureType[] +const SAPIENT_SIGNATURE_TYPES = ['sapient', 'sapient-compact'] satisfies SignatureType[] +const PAYLOAD_VERSION_FILTER = { + 'Major-Version': '1', + 'Minor-Version': '2', +} as const + +function isPlainSignatureType(type: SignatureType): type is (typeof PLAIN_SIGNATURE_TYPES)[number] { + return (PLAIN_SIGNATURE_TYPES as readonly SignatureType[]).includes(type) +} + +function isSapientSignatureType(type: SignatureType): type is (typeof SAPIENT_SIGNATURE_TYPES)[number] { + return (SAPIENT_SIGNATURE_TYPES as readonly SignatureType[]).includes(type) +} + +function normalizeHex(value: Hex.Hex): Hex.Hex { + return Hex.fromBytes(Hex.toBytes(value)) +} + +function normalizeAddress(value: Address.Address): Address.Address { + return Address.checksum(value) +} + +function signerKey(address: Address.Address): string { + return normalizeAddress(address).toLowerCase() +} + +function sapientSignerKey(address: Address.Address, imageHash: Hex.Hex): string { + return `${signerKey(address)}:${normalizeHex(imageHash).toLowerCase()}` +} + +function sameAddress(left: Address.Address | undefined, right: Address.Address | undefined): boolean { + return left === undefined && right === undefined + ? true + : left !== undefined && right !== undefined && Address.isEqual(left, right) +} + +function mergeConfigurations(base: Config.Config | undefined, next: Config.Config): Config.Config { + if (!base) { + return next + } + + if ( + base.threshold !== next.threshold || + base.checkpoint !== next.checkpoint || + !sameAddress(base.checkpointer, next.checkpointer) + ) { + throw new Error('conflicting configuration metadata for the same image hash') + } + + return { + ...base, + topology: Config.mergeTopology(base.topology, next.topology), + } +} + +function fromCallData(call: CallData): Payload.Call { + return { + to: normalizeAddress(call.to), + value: BigInt(call.value), + data: normalizeHex(call.data), + gasLimit: BigInt(call.gasLimit), + delegateCall: call.delegateCall, + onlyFallback: call.onlyFallback, + behaviorOnError: call.behaviorOnError, + } +} + +function fromPayloadRecord(record: ArweavePayloadRecord): Payload.Parented { + switch (record['Payload-Type']) { + case 'calls': + return { + type: 'call', + space: BigInt(record.Space), + nonce: BigInt(record.Nonce), + calls: record.data.map(fromCallData), + } + + case 'message': + return { + type: 'message', + message: normalizeHex(record.data), + } + + case 'config update': + return { + type: 'config-update', + imageHash: normalizeHex(record['To-Config']), + } + + case 'digest': + return { + type: 'digest', + digest: normalizeHex(record.Digest), + } + } +} + +function fromTreeData(tree: TreeData): GenericTree.Tree { + if (typeof tree === 'string') { + return normalizeHex(tree) + } + + if (Array.isArray(tree)) { + return tree.map(fromTreeData) as GenericTree.Branch + } + + return { + type: 'leaf', + value: Bytes.fromHex(tree.data), + } +} + +function fromV2ConfigTree(tree: V2ConfigTreeData): Config.Topology { + if (typeof tree === 'string') { + return normalizeHex(tree) + } + + if (Array.isArray(tree)) { + return [fromV2ConfigTree(tree[0]), fromV2ConfigTree(tree[1])] + } + + if ('address' in tree) { + return { + type: 'signer', + address: normalizeAddress(tree.address), + weight: BigInt(tree.weight), + } + } + + if ('tree' in tree) { + return { + type: 'nested', + weight: BigInt(tree.weight), + threshold: BigInt(tree.threshold), + tree: fromV2ConfigTree(tree.tree), + } + } + + return { + type: 'subdigest', + digest: normalizeHex(tree.subdigest), + } +} + +function fromV3ConfigTree(tree: V3ConfigTreeData): Config.Topology { + if (typeof tree === 'string') { + return normalizeHex(tree) + } + + if (Array.isArray(tree)) { + return [fromV3ConfigTree(tree[0]), fromV3ConfigTree(tree[1])] + } + + if ('address' in tree) { + if ('imageHash' in tree) { + return { + type: 'sapient-signer', + address: normalizeAddress(tree.address), + weight: BigInt(tree.weight), + imageHash: normalizeHex(tree.imageHash), + } + } + + return { + type: 'signer', + address: normalizeAddress(tree.address), + weight: BigInt(tree.weight), + } + } + + if ('tree' in tree) { + return { + type: 'nested', + weight: BigInt(tree.weight), + threshold: BigInt(tree.threshold), + tree: fromV3ConfigTree(tree.tree), + } + } + + return { + type: 'isAnyAddress' in tree && tree.isAnyAddress ? 'any-address-subdigest' : 'subdigest', + digest: normalizeHex(tree.subdigest), + } +} + +function fromConfigData(version: '1' | '2' | '3', data: ConfigData): Config.Config { + switch (version) { + case '1': { + const v1Data = data as V1ConfigData + if (v1Data.signers.length === 0) { + throw new Error('legacy configuration cannot be empty') + } + + return { + threshold: BigInt(v1Data.threshold), + checkpoint: 0n, + topology: Config.flatLeavesToTopology( + v1Data.signers.map((signer) => ({ + type: 'signer' as const, + address: normalizeAddress(signer.address), + weight: BigInt(signer.weight), + })), + ), + } + } + + case '2': { + const v2Data = data as V2ConfigData + return { + threshold: BigInt(v2Data.threshold), + checkpoint: BigInt(v2Data.checkpoint), + topology: fromV2ConfigTree(v2Data.tree), + } + } + + case '3': { + const v3Data = data as V3ConfigData + return { + threshold: BigInt(v3Data.threshold), + checkpoint: BigInt(v3Data.checkpoint), + checkpointer: v3Data.checkpointer ? normalizeAddress(v3Data.checkpointer) : undefined, + topology: fromV3ConfigTree(v3Data.tree), + } + } + } +} + +function fromConfigCarrier( + record: + | ArweaveConfigRecord + | ArweaveConfigUpdatePayloadRecord + | Extract, +): Config.Config { + switch (record.Type) { + case 'config': + return fromConfigData(record.Version, record.data) + + case 'payload': + return fromConfigData(record['To-Version'], record.data) + + case 'wallet': + return fromConfigData(record['Deploy-Version'], record.data) + } +} + +function fromSignatureRecord(record: ArweaveSignatureRecord): Signature.SignatureOfSignerLeaf { + switch (record['Signature-Type']) { + case 'eip-712': + return { type: 'hash', ...OxSignature.from(record.data) } + + case 'eth_sign': + return { type: 'eth_sign', ...OxSignature.from(record.data) } + + case 'erc-1271': + return { + type: 'erc1271', + address: normalizeAddress(record.Signer), + data: normalizeHex(record.data), + } + + case 'sapient': + case 'sapient-compact': + throw new Error(`unexpected sapient signature type ${record['Signature-Type']}`) + } +} + +function fromSapientSignatureRecord(record: ArweaveSapientSignatureRecord): Signature.SignatureOfSapientSignerLeaf { + switch (record['Signature-Type']) { + case 'sapient': + return { + type: 'sapient', + address: normalizeAddress(record.Signer), + data: normalizeHex(record.data), + } + + case 'sapient-compact': + return { + type: 'sapient_compact', + address: normalizeAddress(record.Signer), + data: normalizeHex(record.data), + } + + case 'eip-712': + case 'eth_sign': + case 'erc-1271': + throw new Error(`unexpected plain signature type ${record['Signature-Type']}`) + } +} + +function inferContext(record: ArweaveWalletRecord): Context.Context | undefined { + if ('Context-Factory' in record) { + return { + factory: normalizeAddress(record['Context-Factory']), + stage1: normalizeAddress(record['Context-Stage-1']), + stage2: normalizeAddress(record['Context-Stage-2']), + creationCode: normalizeHex(record['Context-Creation-Code']), + } + } + + const wallet = normalizeAddress(record.Wallet) + const imageHashBytes = Bytes.fromHex(normalizeHex(record['Deploy-Config'])) + + const knownContext = Context.KnownContexts.find((context) => + Address.isEqual(SequenceAddress.from(imageHashBytes, context), wallet), + ) + + if (!knownContext) { + return undefined + } + + return { + factory: knownContext.factory, + stage1: knownContext.stage1, + stage2: knownContext.stage2, + creationCode: knownContext.creationCode, + } +} + +function toRecoveredLikeTopology(topology: Config.Topology): Config.Topology { + if (Config.isNode(topology)) { + return [toRecoveredLikeTopology(topology[0]), toRecoveredLikeTopology(topology[1])] + } + + if (Config.isSignerLeaf(topology)) { + return topology.signature ? { ...topology, signed: true } : topology + } + + if (Config.isSapientSignerLeaf(topology)) { + if (topology.signature) { + return { ...topology, signed: true } + } + + return Hex.fromBytes(Config.hashConfiguration(topology)) + } + + if (Config.isNestedLeaf(topology)) { + return { + ...topology, + tree: toRecoveredLikeTopology(topology.tree), + } + } + + return topology +} + +function fillTopologyWithSignatures( + configuration: Config.Config, + signatures: Map, +): Config.Topology { + return Signature.fillLeaves(configuration.topology, (leaf) => { + if (Config.isSapientSignerLeaf(leaf)) { + const signature = signatures.get(sapientSignerKey(leaf.address, leaf.imageHash)) + return signature && Signature.isSignatureOfSapientSignerLeaf(signature) ? signature : undefined + } + + const signature = signatures.get(signerKey(leaf.address)) + return signature && !Signature.isSignatureOfSapientSignerLeaf(signature) ? signature : undefined + }) +} + +export class Reader implements ReaderInterface { + constructor(private readonly options: Options = defaults) {} + + private async findEntries( + filter: { [name: string]: undefined | string | string[] }, + options?: { maxResults?: number }, + ): Promise { + const items = await findItems(filter, { ...this.options, maxResults: options?.maxResults }) + return Object.entries(items).map(([id, tags]) => ({ id, tags })) + } + + private async loadRecord(entry: ItemEntry): Promise { + const response = await fetchItem(entry.id, this.options.rateLimitRetryDelayMs, this.options.arweaveUrl) + if (!response.ok) { + throw new Error(`failed to fetch arweave item ${entry.id}: ${response.status}`) + } + + const data = + entry.tags['Content-Type'] === 'application/json' ? await response.json() : (await response.text()).trim() + return { ...entry.tags, data } as T + } + + private async findFirstRecord(filter: { + [name: string]: undefined | string | string[] + }): Promise { + const [entry] = await this.findEntries(filter, { maxResults: 1 }) + return entry ? this.loadRecord(entry) : undefined + } + + async getConfiguration(imageHash: Hex.Hex): Promise { + const normalizedImageHash = normalizeHex(imageHash) + const configEntries = await this.findEntries({ Type: 'config', Config: normalizedImageHash }) + + let configuration: Config.Config | undefined + + for (const record of await Promise.all(configEntries.map((entry) => this.loadRecord(entry)))) { + configuration = mergeConfigurations(configuration, fromConfigCarrier(record)) + } + + if (configuration) { + return configuration + } + + const [walletEntries, payloadEntries] = await Promise.all([ + this.findEntries({ + Type: 'wallet', + 'Deploy-Config': normalizedImageHash, + 'Deploy-Config-Attached': 'true', + }), + this.findEntries({ + Type: 'payload', + ...PAYLOAD_VERSION_FILTER, + 'Payload-Type': 'config update', + 'To-Config': normalizedImageHash, + }), + ]) + + for (const record of await Promise.all(walletEntries.map((entry) => this.loadRecord(entry)))) { + if (record['Deploy-Config-Attached'] === 'true') { + configuration = mergeConfigurations(configuration, fromConfigCarrier(record)) + } + } + + for (const record of await Promise.all( + payloadEntries.map((entry) => this.loadRecord(entry)), + )) { + configuration = mergeConfigurations(configuration, fromConfigCarrier(record)) + } + + return configuration + } + + async getDeploy(wallet: Address.Address): Promise<{ imageHash: Hex.Hex; context: Context.Context } | undefined> { + const record = await this.findFirstRecord({ + Type: 'wallet', + Wallet: normalizeAddress(wallet), + }) + + if (!record) { + return undefined + } + + const context = inferContext(record) + if (!context) { + return undefined + } + + return { + imageHash: normalizeHex(record['Deploy-Config']), + context, + } + } + + private async getWalletsGeneric( + filter: { [name: string]: undefined | string | string[] }, + signatureFrom: (record: TRecord) => TSignature, + ): Promise> { + const payloads = new Map< + Hex.Hex, + Promise<{ chainId: number; payload: Payload.Parented; wallet: Address.Address } | undefined> + >() + const response: WitnessMap = {} + + for (const entry of await this.findEntries(filter)) { + const wallet = normalizeAddress(entry.tags.Wallet as Address.Address) + if (response[wallet]) { + continue + } + + const record = await this.loadRecord(entry) + const subdigest = normalizeHex(record.Subdigest) + const payloadPromise = payloads.get(subdigest) ?? this.getPayload(subdigest) + payloads.set(subdigest, payloadPromise) + const payload = await payloadPromise + + if (!payload) { + continue + } + + response[wallet] = { + chainId: payload.chainId, + payload: payload.payload, + signature: signatureFrom(record), + } + } + + return normalizeAddressKeys(response) + } + + async getWallets(signer: Address.Address): Promise<{ + [wallet: Address.Address]: { + chainId: number + payload: Payload.Parented + signature: Signature.SignatureOfSignerLeaf + } + }> { + return this.getWalletsGeneric( + { + Type: 'signature', + Signer: normalizeAddress(signer), + Witness: 'true', + 'Signature-Type': [...PLAIN_SIGNATURE_TYPES], + }, + fromSignatureRecord, + ) + } + + async getWalletsForSapient( + signer: Address.Address, + imageHash: Hex.Hex, + ): Promise<{ + [wallet: Address.Address]: { + chainId: number + payload: Payload.Parented + signature: Signature.SignatureOfSapientSignerLeaf + } + }> { + return this.getWalletsGeneric( + { + Type: 'signature', + Signer: normalizeAddress(signer), + 'Image-Hash': normalizeHex(imageHash), + Witness: 'true', + 'Signature-Type': [...SAPIENT_SIGNATURE_TYPES], + }, + fromSapientSignatureRecord, + ) + } + + private async getWitnessGeneric( + filter: { [name: string]: undefined | string | string[] }, + signatureFrom: (record: TRecord) => TSignature, + ): Promise | undefined> { + const entries = await this.findEntries(filter) + + for (const entry of entries) { + const record = await this.loadRecord(entry) + const payload = await this.getPayload(record.Subdigest) + if (!payload) { + continue + } + + return { + chainId: payload.chainId, + payload: payload.payload, + signature: signatureFrom(record), + } + } + } + + getWitnessFor( + wallet: Address.Address, + signer: Address.Address, + ): Promise<{ chainId: number; payload: Payload.Parented; signature: Signature.SignatureOfSignerLeaf } | undefined> { + return this.getWitnessGeneric( + { + Type: 'signature', + Wallet: normalizeAddress(wallet), + Signer: normalizeAddress(signer), + Witness: 'true', + 'Signature-Type': [...PLAIN_SIGNATURE_TYPES], + }, + fromSignatureRecord, + ) + } + + getWitnessForSapient( + wallet: Address.Address, + signer: Address.Address, + imageHash: Hex.Hex, + ): Promise< + { chainId: number; payload: Payload.Parented; signature: Signature.SignatureOfSapientSignerLeaf } | undefined + > { + return this.getWitnessGeneric( + { + Type: 'signature', + Wallet: normalizeAddress(wallet), + Signer: normalizeAddress(signer), + 'Image-Hash': normalizeHex(imageHash), + Witness: 'true', + 'Signature-Type': [...SAPIENT_SIGNATURE_TYPES], + }, + fromSapientSignatureRecord, + ) + } + + async getConfigurationUpdates( + wallet: Address.Address, + fromImageHash: Hex.Hex, + options?: { allUpdates?: boolean }, + ): Promise> { + const normalizedWallet = normalizeAddress(wallet) + const configuration = await this.getConfiguration(fromImageHash) + if (!configuration) { + return [] + } + + const { signers, sapientSigners } = Config.getSigners(configuration) + const allowedSigners = new Set(signers.map(signerKey)) + const allowedSapientSigners = new Set( + sapientSigners.map(({ address, imageHash }) => sapientSignerKey(address, imageHash)), + ) + const candidates = new Map() + + for (const entry of await this.findEntries({ Type: 'config update', Wallet: normalizedWallet })) { + const { tags } = entry + const nextImageHash = normalizeHex(tags['To-Config'] as Hex.Hex) + const checkpoint = BigInt(tags['To-Checkpoint'] as string) + + if (checkpoint <= configuration.checkpoint) { + continue + } + + let signatureEntryKey: string | undefined + if (isPlainSignatureType(tags['Signature-Type'] as SignatureType)) { + const key = signerKey(tags.Signer as Address.Address) + if (allowedSigners.has(key)) { + signatureEntryKey = key + } + } else if (isSapientSignatureType(tags['Signature-Type'] as SignatureType) && 'Image-Hash' in tags) { + const key = sapientSignerKey(tags.Signer as Address.Address, tags['Image-Hash'] as Hex.Hex) + if (allowedSapientSigners.has(key)) { + signatureEntryKey = key + } + } + + if (!signatureEntryKey) { + continue + } + + const candidate = candidates.get(nextImageHash) ?? { + nextImageHash, + checkpoint, + signatureEntries: new Map(), + } + + if (!candidate.signatureEntries.has(signatureEntryKey)) { + candidate.signatureEntries.set(signatureEntryKey, entry) + } + + candidates.set(nextImageHash, candidate) + } + + let best: + | { + nextImageHash: Hex.Hex + checkpoint: bigint + signature: Signature.RawSignature + } + | undefined + + const sortedCandidates = Array.from(candidates.values()).sort((left, right) => { + if (left.checkpoint === right.checkpoint) { + return 0 + } + + return left.checkpoint > right.checkpoint ? (options?.allUpdates ? 1 : -1) : options?.allUpdates ? -1 : 1 + }) + + for (const candidate of sortedCandidates) { + if (best && candidate.checkpoint <= best.checkpoint) { + continue + } + + const signatures = new Map() + let topology = configuration.topology + + for (const entry of candidate.signatureEntries.values()) { + if (isSapientSignatureType(entry.tags['Signature-Type'] as SignatureType)) { + const record = await this.loadRecord(entry) + signatures.set(sapientSignerKey(record.Signer, record['Image-Hash']), fromSapientSignatureRecord(record)) + } else { + const record = await this.loadRecord(entry) + signatures.set(signerKey(record.Signer), fromSignatureRecord(record)) + } + + topology = fillTopologyWithSignatures(configuration, signatures) + const { weight } = Config.getWeight(topology, () => false) + if (weight >= configuration.threshold) { + break + } + } + + const { weight } = Config.getWeight(topology, () => false) + if (weight < configuration.threshold) { + continue + } + + best = { + nextImageHash: candidate.nextImageHash, + checkpoint: candidate.checkpoint, + signature: { + noChainId: true, + configuration: { + threshold: configuration.threshold, + checkpoint: configuration.checkpoint, + checkpointer: configuration.checkpointer, + topology: toRecoveredLikeTopology(topology), + }, + }, + } + } + + if (!best) { + return [] + } + + const nextStep = await this.getConfigurationUpdates(normalizedWallet, best.nextImageHash, { allUpdates: true }) + return [{ imageHash: best.nextImageHash, signature: best.signature }, ...nextStep] + } + + async getTree(imageHash: Hex.Hex): Promise { + const record = await this.findFirstRecord({ + Type: 'tree', + Tree: normalizeHex(imageHash), + }) + + return record ? fromTreeData(record.data) : undefined + } + + async getPayload( + digest: Hex.Hex, + ): Promise<{ chainId: number; payload: Payload.Parented; wallet: Address.Address } | undefined> { + const record = await this.findFirstRecord({ + Type: 'payload', + ...PAYLOAD_VERSION_FILTER, + Payload: normalizeHex(digest), + }) + + if (!record) { + return undefined + } + + return { + chainId: Number(record['Chain-ID']), + payload: fromPayloadRecord(record), + wallet: normalizeAddress(record.Address), + } + } +} diff --git a/packages/wallet/core/src/state/arweave/schema.ts b/packages/wallet/core/src/state/arweave/schema.ts new file mode 100644 index 0000000000..059579cb08 --- /dev/null +++ b/packages/wallet/core/src/state/arweave/schema.ts @@ -0,0 +1,369 @@ +import { Address, Hex } from 'ox' + +export type BooleanString = 'true' | 'false' +export type IntegerString = `${number}` +export type ConfigVersion = '1' | '2' | '3' +export type WalletMajorVersion = '1' | '2' +export type PayloadType = 'calls' | 'message' | 'config update' | 'digest' +export type SignatureType = 'eip-712' | 'eth_sign' | 'erc-1271' | 'sapient' | 'sapient-compact' +export type BehaviorOnError = 'ignore' | 'revert' | 'abort' + +export interface V1ConfigSignerData { + weight: number + address: Address.Address +} + +export interface V1ConfigData { + threshold: number + signers: Array +} + +export interface V2ConfigAddressLeafData { + weight: number + address: Address.Address +} + +export interface V2ConfigNestedLeafData { + weight: number + threshold: number + tree: V2ConfigTreeData +} + +export interface V2ConfigSubdigestLeafData { + subdigest: Hex.Hex +} + +export type V2ConfigTreeData = + | Hex.Hex + | [V2ConfigTreeData, V2ConfigTreeData] + | V2ConfigAddressLeafData + | V2ConfigNestedLeafData + | V2ConfigSubdigestLeafData + +export interface V2ConfigData { + threshold: number + checkpoint: number + tree: V2ConfigTreeData +} + +export interface V3ConfigAddressLeafData { + weight: number + address: Address.Address +} + +export interface V3ConfigSapientSignerLeafData { + weight: number + address: Address.Address + imageHash: Hex.Hex +} + +export interface V3ConfigNestedLeafData { + weight: number + threshold: number + tree: V3ConfigTreeData +} + +export interface V3ConfigSubdigestLeafData { + subdigest: Hex.Hex +} + +export interface V3ConfigAnyAddressSubdigestLeafData { + subdigest: Hex.Hex + isAnyAddress: true +} + +export type V3ConfigTreeData = + | Hex.Hex + | [V3ConfigTreeData, V3ConfigTreeData] + | V3ConfigAddressLeafData + | V3ConfigSapientSignerLeafData + | V3ConfigNestedLeafData + | V3ConfigSubdigestLeafData + | V3ConfigAnyAddressSubdigestLeafData + +export interface V3ConfigData { + threshold: number + checkpoint: IntegerString + tree: V3ConfigTreeData + checkpointer?: Address.Address +} + +export type ConfigData = V1ConfigData | V2ConfigData | V3ConfigData + +export interface TreeLeafData { + data: Hex.Hex +} + +export type TreeData = Hex.Hex | TreeLeafData | Array + +export interface CallData { + to: Address.Address + value: IntegerString + data: Hex.Hex + gasLimit: IntegerString + delegateCall: boolean + onlyFallback: boolean + behaviorOnError: BehaviorOnError +} + +export interface ArweaveRecordBase< + TType extends string, + TMajorVersion extends string, + TMinorVersion extends string, + TContentType extends string, +> { + Type: TType + 'Major-Version': TMajorVersion + 'Minor-Version': TMinorVersion + 'Content-Type': TContentType +} + +export interface ArweaveConfigRecordBase extends ArweaveRecordBase<'config', '1', '0', 'application/json'> { + Config: Hex.Hex + Complete: BooleanString + 'Signers-Count': IntegerString + 'Signers-Bloom': Hex.Hex +} + +export interface ArweaveV1ConfigRecord extends ArweaveConfigRecordBase { + Version: '1' + data: V1ConfigData +} + +export interface ArweaveV2ConfigRecord extends ArweaveConfigRecordBase { + Version: '2' + data: V2ConfigData +} + +export interface ArweaveV3ConfigRecord extends ArweaveConfigRecordBase { + Version: '3' + data: V3ConfigData +} + +export type ArweaveConfigRecord = ArweaveV1ConfigRecord | ArweaveV2ConfigRecord | ArweaveV3ConfigRecord + +export interface ArweaveTreeRecord extends ArweaveRecordBase<'tree', '1', '0', 'application/json'> { + Tree: Hex.Hex + Complete: BooleanString + data: TreeData +} + +export interface ArweaveWalletRecordBase extends ArweaveRecordBase< + 'wallet', + WalletMajorVersion, + '0', + 'application/json' +> { + Wallet: Address.Address + 'Deploy-Config': Hex.Hex + 'Deploy-Version': ConfigVersion + 'Deploy-Config-Attached': BooleanString + 'Deploy-Config-Complete': BooleanString + 'Deploy-Signers-Count': IntegerString + 'Deploy-Signers-Bloom': Hex.Hex +} + +export interface ArweaveWalletDefaultContext { + 'Major-Version': '1' +} + +export interface ArweaveWalletCustomContext { + 'Major-Version': '2' + 'Context-Factory': Address.Address + 'Context-Stage-1': Address.Address + 'Context-Stage-2': Address.Address + 'Context-Guest': Address.Address + 'Context-Creation-Code': Hex.Hex +} + +export interface ArweaveWalletDetachedData { + 'Deploy-Config-Attached': 'false' + 'Deploy-Config-Complete': 'false' + data: null +} + +export interface ArweaveWalletWithV1DeployConfig { + 'Deploy-Config-Attached': 'true' + 'Deploy-Version': '1' + data: V1ConfigData +} + +export interface ArweaveWalletWithV2DeployConfig { + 'Deploy-Config-Attached': 'true' + 'Deploy-Version': '2' + data: V2ConfigData +} + +export interface ArweaveWalletWithV3DeployConfig { + 'Deploy-Config-Attached': 'true' + 'Deploy-Version': '3' + data: V3ConfigData +} + +export type ArweaveWalletRecord = ArweaveWalletRecordBase & + (ArweaveWalletDefaultContext | ArweaveWalletCustomContext) & + ( + | ArweaveWalletDetachedData + | ArweaveWalletWithV1DeployConfig + | ArweaveWalletWithV2DeployConfig + | ArweaveWalletWithV3DeployConfig + ) + +export interface ArweavePayloadRecordBase extends ArweaveRecordBase<'payload', '1', '2', 'application/json'> { + Payload: Hex.Hex + Address: Address.Address + 'Chain-ID': IntegerString + 'Payload-Type': PayloadType +} + +export interface ArweaveCallsPayloadRecord extends ArweavePayloadRecordBase { + 'Payload-Type': 'calls' + Space: IntegerString + Nonce: IntegerString + data: Array +} + +export interface ArweaveMessagePayloadRecord extends ArweavePayloadRecordBase { + 'Payload-Type': 'message' + data: Hex.Hex +} + +export interface ArweaveConfigUpdatePayloadRecordBase extends ArweavePayloadRecordBase { + 'Payload-Type': 'config update' + 'To-Config': Hex.Hex + 'To-Checkpoint': IntegerString + 'To-Config-Complete': BooleanString + 'To-Signers-Count': IntegerString + 'To-Signers-Bloom': Hex.Hex +} + +export interface ArweaveV1ConfigUpdatePayloadRecord extends ArweaveConfigUpdatePayloadRecordBase { + 'To-Version': '1' + data: V1ConfigData +} + +export interface ArweaveV2ConfigUpdatePayloadRecord extends ArweaveConfigUpdatePayloadRecordBase { + 'To-Version': '2' + data: V2ConfigData +} + +export interface ArweaveV3ConfigUpdatePayloadRecord extends ArweaveConfigUpdatePayloadRecordBase { + 'To-Version': '3' + data: V3ConfigData +} + +export interface ArweaveDigestPayloadRecord extends ArweavePayloadRecordBase { + 'Payload-Type': 'digest' + Digest: Hex.Hex + data: null +} + +export type ArweavePayloadRecord = + | ArweaveCallsPayloadRecord + | ArweaveMessagePayloadRecord + | ArweaveV1ConfigUpdatePayloadRecord + | ArweaveV2ConfigUpdatePayloadRecord + | ArweaveV3ConfigUpdatePayloadRecord + | ArweaveDigestPayloadRecord + +export interface ArweaveConfigUpdateTags { + Type: 'config update' + 'To-Config': Hex.Hex + 'To-Checkpoint': IntegerString + 'To-Config-Complete': BooleanString + 'To-Signers-Count': IntegerString + 'To-Signers-Bloom': Hex.Hex +} + +export interface ArweavePlainSignatureTags { + Type: 'signature' +} + +export interface ArweaveSignatureDigestTags { + 'Major-Version': '1' + Digest: Hex.Hex +} + +export interface ArweaveSignatureSubdigestTags { + 'Major-Version': '2' +} + +export interface ArweaveSignatureBlockTags { + 'Block-Number': IntegerString + 'Block-Hash': Hex.Hex +} + +export interface ArweaveSignatureRecordBase extends ArweaveRecordBase< + 'signature' | 'config update', + '1' | '2', + '0', + 'text/plain' +> { + 'Signature-Type': SignatureType + Signer: Address.Address + Subdigest: Hex.Hex + Wallet: Address.Address + 'Chain-ID': IntegerString + Witness: BooleanString + data: Hex.Hex +} + +export type ArweaveSignatureRecord = + | (ArweaveSignatureRecordBase & ArweavePlainSignatureTags & ArweaveSignatureDigestTags) + | (ArweaveSignatureRecordBase & ArweavePlainSignatureTags & ArweaveSignatureSubdigestTags) + | (ArweaveSignatureRecordBase & ArweavePlainSignatureTags & ArweaveSignatureDigestTags & ArweaveSignatureBlockTags) + | (ArweaveSignatureRecordBase & ArweavePlainSignatureTags & ArweaveSignatureSubdigestTags & ArweaveSignatureBlockTags) + | (ArweaveSignatureRecordBase & ArweaveConfigUpdateTags & ArweaveSignatureDigestTags) + | (ArweaveSignatureRecordBase & ArweaveConfigUpdateTags & ArweaveSignatureSubdigestTags) + | (ArweaveSignatureRecordBase & ArweaveConfigUpdateTags & ArweaveSignatureDigestTags & ArweaveSignatureBlockTags) + | (ArweaveSignatureRecordBase & ArweaveConfigUpdateTags & ArweaveSignatureSubdigestTags & ArweaveSignatureBlockTags) + +export interface ArweaveSapientSignatureRecordBase extends ArweaveRecordBase< + 'signature' | 'config update', + '2', + '0', + 'text/plain' +> { + 'Signature-Type': SignatureType + Signer: Address.Address + 'Image-Hash': Hex.Hex + Subdigest: Hex.Hex + Wallet: Address.Address + 'Chain-ID': IntegerString + 'Block-Number': IntegerString + 'Block-Hash': Hex.Hex + Witness: BooleanString + data: Hex.Hex +} + +export type ArweaveSapientSignatureRecord = + | (ArweaveSapientSignatureRecordBase & ArweavePlainSignatureTags) + | (ArweaveSapientSignatureRecordBase & ArweaveConfigUpdateTags) + +export interface ArweaveMigrationRecord extends ArweaveRecordBase<'migration', '1', '0', 'text/plain'> { + Migration: Address.Address + 'Chain-ID': IntegerString + 'From-Version': ConfigVersion + 'From-Config': Hex.Hex + 'From-Config-Complete': BooleanString + 'From-Signers-Count': IntegerString + 'From-Signers-Bloom': Hex.Hex + 'To-Version': ConfigVersion + 'To-Config': Hex.Hex + 'To-Config-Complete': BooleanString + 'To-Signers-Count': IntegerString + 'To-Signers-Bloom': Hex.Hex + Executor: Address.Address + data: Hex.Hex +} + +export type ArweaveRecord = + | ArweaveConfigRecord + | ArweaveTreeRecord + | ArweaveWalletRecord + | ArweavePayloadRecord + | ArweaveSignatureRecord + | ArweaveSapientSignatureRecord + | ArweaveMigrationRecord + +export type ArweaveObject = ArweaveRecord diff --git a/packages/wallet/core/src/state/index.ts b/packages/wallet/core/src/state/index.ts index 53e1699087..94faee061b 100644 --- a/packages/wallet/core/src/state/index.ts +++ b/packages/wallet/core/src/state/index.ts @@ -79,9 +79,10 @@ export interface Writer { export type MaybePromise = T | Promise -export * as Local from './local/index.js' +export * from './cached.js' +export * from './debug.js' export * from './utils.js' +export * as Arweave from './arweave/index.js' +export * as Local from './local/index.js' export * as Remote from './remote/index.js' -export * from './cached.js' export * as Sequence from './sequence/index.js' -export * from './debug.js' diff --git a/packages/wallet/core/test/state/arweave/arweave.test.ts b/packages/wallet/core/test/state/arweave/arweave.test.ts new file mode 100644 index 0000000000..3068336bc3 --- /dev/null +++ b/packages/wallet/core/test/state/arweave/arweave.test.ts @@ -0,0 +1,120 @@ +import { Address } from 'ox' +import { describe, expect, it } from 'vitest' + +import { Arweave, Reader, Sequence } from '../../../src/state/index' + +const TEST_TIMEOUT_MS = 10_000 + +const tests: { [method in keyof Reader]: { [description: string]: Parameters } } = { + getConfiguration: { + 'image hash: 0xfd32e01d7e814292f49f57e79722ca66423833acf8f25eba770faf3483ff3e78': [ + '0xfd32e01d7e814292f49f57e79722ca66423833acf8f25eba770faf3483ff3e78', + ], + }, + getDeploy: { + 'wallet: 0x47E0e44DE649B35Cf7863998Be6C5a7D5d8c63bE': ['0x47E0e44DE649B35Cf7863998Be6C5a7D5d8c63bE'], + }, + getWallets: { + 'signer: 0x94835215CaA1aD3E304F9A7E2148623fe661dEB7': ['0x94835215CaA1aD3E304F9A7E2148623fe661dEB7'], + }, + getWalletsForSapient: { + 'signer: 0x000000000000AB36D17eB1150116371520565205, image hash: 0xeef69774e1cb488a71f6d235c858fa564134ee7c3acda9ff116b6c9d42b3cee3': + [ + '0x000000000000AB36D17eB1150116371520565205', + '0xeef69774e1cb488a71f6d235c858fa564134ee7c3acda9ff116b6c9d42b3cee3', + ], + }, + getWitnessFor: { + 'wallet: 0x47E0e44DE649B35Cf7863998Be6C5a7D5d8c63bE, signer: 0x94835215CaA1aD3E304F9A7E2148623fe661dEB7': [ + '0x47E0e44DE649B35Cf7863998Be6C5a7D5d8c63bE', + '0x94835215CaA1aD3E304F9A7E2148623fe661dEB7', + ], + }, + getWitnessForSapient: { + 'wallet: 0x47E0e44DE649B35Cf7863998Be6C5a7D5d8c63bE, signer: 0x000000000000AB36D17eB1150116371520565205, image hash: 0xeef69774e1cb488a71f6d235c858fa564134ee7c3acda9ff116b6c9d42b3cee3': + [ + '0x47E0e44DE649B35Cf7863998Be6C5a7D5d8c63bE', + '0x000000000000AB36D17eB1150116371520565205', + '0xeef69774e1cb488a71f6d235c858fa564134ee7c3acda9ff116b6c9d42b3cee3', + ], + }, + getConfigurationUpdates: { + 'wallet: 0x135769a58639b4Fa7d779a9df9B57A706FBCa816, from: 0xaa14aff91091e94d7521625ab1c713273e86a8c21a0afb6cee35be28af47738a': + [ + '0x135769a58639b4Fa7d779a9df9B57A706FBCa816', + '0xaa14aff91091e94d7521625ab1c713273e86a8c21a0afb6cee35be28af47738a', + ], + }, + getTree: { + 'image hash: 0xeef69774e1cb488a71f6d235c858fa564134ee7c3acda9ff116b6c9d42b3cee3': [ + '0xeef69774e1cb488a71f6d235c858fa564134ee7c3acda9ff116b6c9d42b3cee3', + ], + }, + getPayload: { + 'calls payload: 0xc78f3951686b7f16f39e25aea1fd5acc0e2177083c170b4c962be6cd45630576': [ + '0xc78f3951686b7f16f39e25aea1fd5acc0e2177083c170b4c962be6cd45630576', + ], + 'message payload: 0x3a841ba3163a7a19cd168373df1144d38130b2f46b8d6eac956127f06fffe4f4': [ + '0x3a841ba3163a7a19cd168373df1144d38130b2f46b8d6eac956127f06fffe4f4', + ], + 'config update payload: 0xcae631660ffa90bddc5e9b4fa9c11692a53062a61640fb958f3f2959d22fe54b': [ + '0xcae631660ffa90bddc5e9b4fa9c11692a53062a61640fb958f3f2959d22fe54b', + ], + 'digest payload: 0xcd3c291e0939f029aaa4b4f292d5d2b2ce43baf98046d9abc2a3e8284b253432': [ + '0xcd3c291e0939f029aaa4b4f292d5d2b2ce43baf98046d9abc2a3e8284b253432', + ], + }, +} + +function normalize(value: any): any { + switch (typeof value) { + case 'string': + if (Address.validate(value)) { + return Address.checksum(value) + } + + break + + case 'object': + if (value === null) { + return value + } + + if (Array.isArray(value)) { + return value.map(normalize) + } + + return Object.fromEntries( + Object.entries(value) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [Address.validate(key) ? Address.checksum(key) : key, normalize(value)]), + ) + } + + return value +} + +describe('Arweave state reader', () => { + const arweave = new Arweave.Reader() + const sequence = new Sequence.Provider() + + const methods = Object.entries(tests).filter(([, methodTests]) => Object.keys(methodTests).length > 0) + if (methods.length === 0) { + it.skip('no configured test cases', () => {}) + } + + for (const [method, methodTests] of methods) { + describe(method, () => { + for (const [description, args] of Object.entries(methodTests)) { + it( + description, + async () => { + const [actual, expected] = await Promise.all([arweave[method](...args), sequence[method](...args)]) + expect(normalize(actual)).toEqual(normalize(expected)) + }, + TEST_TIMEOUT_MS, + ) + } + }) + } +}) From c38d1507554baf592b0ec059c228b1e14a78cc0c Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 10:13:57 -0400 Subject: [PATCH 02/12] use default arweave owner --- packages/wallet/core/src/state/arweave/arweave.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/core/src/state/arweave/arweave.ts b/packages/wallet/core/src/state/arweave/arweave.ts index c3bb81f54a..c467d51322 100644 --- a/packages/wallet/core/src/state/arweave/arweave.ts +++ b/packages/wallet/core/src/state/arweave/arweave.ts @@ -19,7 +19,7 @@ export async function findItems( options?: Options & { pageSize?: number; maxResults?: number }, ): Promise<{ [id: string]: { [tag: string]: string } }> { const namespace = options?.namespace ?? defaults.namespace - const owners = options?.owners + const owners = options?.owners ?? defaults.owners const graphqlUrl = options?.graphqlUrl ?? defaults.graphqlUrl const rateLimitRetryDelayMs = options?.rateLimitRetryDelayMs ?? defaults.rateLimitRetryDelayMs const pageSize = options?.pageSize ?? 100 @@ -38,7 +38,7 @@ export async function findItems( 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(', ')}]${owners === undefined ? '' : `, owners: [${owners.map((owner) => `"${owner}"`).join(', ')}]`}) { + transactions(sort: HEIGHT_DESC, ${edges.length ? `first: ${pageSize}, after: "${edges[edges.length - 1]!.cursor}"` : `first: ${pageSize}`}, tags: [${tags.join(', ')}]${owners.length ? `, owners: [${owners.map((owner) => `"${owner}"`).join(', ')}]` : ''}) { pageInfo { hasNextPage } From 5c25b88ec637fa3a2dce1ca2729de3f0d686259e Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 10:23:21 -0400 Subject: [PATCH 03/12] hard limit to maxResults --- packages/wallet/core/src/state/arweave/arweave.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/wallet/core/src/state/arweave/arweave.ts b/packages/wallet/core/src/state/arweave/arweave.ts index c467d51322..b3cadb7340 100644 --- a/packages/wallet/core/src/state/arweave/arweave.ts +++ b/packages/wallet/core/src/state/arweave/arweave.ts @@ -36,9 +36,11 @@ export async function findItems( 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 results = maxResults === undefined ? pageSize : Math.min(pageSize, maxResults - edges.length) + const query = ` query { - transactions(sort: HEIGHT_DESC, ${edges.length ? `first: ${pageSize}, after: "${edges[edges.length - 1]!.cursor}"` : `first: ${pageSize}`}, tags: [${tags.join(', ')}]${owners.length ? `, owners: [${owners.map((owner) => `"${owner}"`).join(', ')}]` : ''}) { + transactions(sort: HEIGHT_DESC, ${edges.length ? `first: ${results}, after: "${edges[edges.length - 1]!.cursor}"` : `first: ${results}`}, tags: [${tags.join(', ')}]${owners.length ? `, owners: [${owners.map((owner) => `"${owner}"`).join(', ')}]` : ''}) { pageInfo { hasNextPage } @@ -77,7 +79,7 @@ export async function findItems( data: { transactions }, } = await response.json() - edges.push(...transactions.edges) + edges.push(...transactions.edges.slice(0, results)) hasNextPage = transactions.pageInfo.hasNextPage } From 9cd7c7f39b5c242e83a5a753b274b10194d0f273 Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 10:40:46 -0400 Subject: [PATCH 04/12] TEST_TIMEOUT_MS: 10_000 -> 20_000 --- packages/wallet/core/test/state/arweave/arweave.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/core/test/state/arweave/arweave.test.ts b/packages/wallet/core/test/state/arweave/arweave.test.ts index 3068336bc3..3c7931e3e1 100644 --- a/packages/wallet/core/test/state/arweave/arweave.test.ts +++ b/packages/wallet/core/test/state/arweave/arweave.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { Arweave, Reader, Sequence } from '../../../src/state/index' -const TEST_TIMEOUT_MS = 10_000 +const TEST_TIMEOUT_MS = 20_000 const tests: { [method in keyof Reader]: { [description: string]: Parameters } } = { getConfiguration: { From b7690d76c42b3222596ea69edf68569c3162d70a Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 10:53:32 -0400 Subject: [PATCH 05/12] fix allUpdates = true behaviour --- packages/wallet/core/src/state/arweave/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index 5fa9538af0..b9447310fe 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -757,6 +757,10 @@ export class Reader implements ReaderInterface { }, }, } + + if (options?.allUpdates) { + break + } } if (!best) { From 53db79524600ad584bc30964d393bfc35d121b19 Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 11:04:15 -0400 Subject: [PATCH 06/12] Revert "fix allUpdates = true behaviour" This reverts commit b7690d76c42b3222596ea69edf68569c3162d70a. --- packages/wallet/core/src/state/arweave/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index b9447310fe..5fa9538af0 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -757,10 +757,6 @@ export class Reader implements ReaderInterface { }, }, } - - if (options?.allUpdates) { - break - } } if (!best) { From 3d519dba9155582f9b8831c6b214486aa6e26898 Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 10:53:32 -0400 Subject: [PATCH 07/12] fix allUpdates = true behaviour --- packages/wallet/core/src/state/arweave/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index 5fa9538af0..b9447310fe 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -757,6 +757,10 @@ export class Reader implements ReaderInterface { }, }, } + + if (options?.allUpdates) { + break + } } if (!best) { From 385aac9db6b1ffb123a8fc5bf9d1f41ce1dd6809 Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 11:25:17 -0400 Subject: [PATCH 08/12] Revert "fix allUpdates = true behaviour" This reverts commit 3d519dba9155582f9b8831c6b214486aa6e26898. --- packages/wallet/core/src/state/arweave/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index b9447310fe..5fa9538af0 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -757,10 +757,6 @@ export class Reader implements ReaderInterface { }, }, } - - if (options?.allUpdates) { - break - } } if (!best) { From a9ae8d42fb077ae6f3997383f89f71cc965f06ed Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 11:39:45 -0400 Subject: [PATCH 09/12] remove arweave state reader getConfigurationUpdates implementation --- .../wallet/core/src/state/arweave/index.ts | 118 ------------------ 1 file changed, 118 deletions(-) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index 5fa9538af0..98efe119a2 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -647,124 +647,6 @@ export class Reader implements ReaderInterface { fromImageHash: Hex.Hex, options?: { allUpdates?: boolean }, ): Promise> { - const normalizedWallet = normalizeAddress(wallet) - const configuration = await this.getConfiguration(fromImageHash) - if (!configuration) { - return [] - } - - const { signers, sapientSigners } = Config.getSigners(configuration) - const allowedSigners = new Set(signers.map(signerKey)) - const allowedSapientSigners = new Set( - sapientSigners.map(({ address, imageHash }) => sapientSignerKey(address, imageHash)), - ) - const candidates = new Map() - - for (const entry of await this.findEntries({ Type: 'config update', Wallet: normalizedWallet })) { - const { tags } = entry - const nextImageHash = normalizeHex(tags['To-Config'] as Hex.Hex) - const checkpoint = BigInt(tags['To-Checkpoint'] as string) - - if (checkpoint <= configuration.checkpoint) { - continue - } - - let signatureEntryKey: string | undefined - if (isPlainSignatureType(tags['Signature-Type'] as SignatureType)) { - const key = signerKey(tags.Signer as Address.Address) - if (allowedSigners.has(key)) { - signatureEntryKey = key - } - } else if (isSapientSignatureType(tags['Signature-Type'] as SignatureType) && 'Image-Hash' in tags) { - const key = sapientSignerKey(tags.Signer as Address.Address, tags['Image-Hash'] as Hex.Hex) - if (allowedSapientSigners.has(key)) { - signatureEntryKey = key - } - } - - if (!signatureEntryKey) { - continue - } - - const candidate = candidates.get(nextImageHash) ?? { - nextImageHash, - checkpoint, - signatureEntries: new Map(), - } - - if (!candidate.signatureEntries.has(signatureEntryKey)) { - candidate.signatureEntries.set(signatureEntryKey, entry) - } - - candidates.set(nextImageHash, candidate) - } - - let best: - | { - nextImageHash: Hex.Hex - checkpoint: bigint - signature: Signature.RawSignature - } - | undefined - - const sortedCandidates = Array.from(candidates.values()).sort((left, right) => { - if (left.checkpoint === right.checkpoint) { - return 0 - } - - return left.checkpoint > right.checkpoint ? (options?.allUpdates ? 1 : -1) : options?.allUpdates ? -1 : 1 - }) - - for (const candidate of sortedCandidates) { - if (best && candidate.checkpoint <= best.checkpoint) { - continue - } - - const signatures = new Map() - let topology = configuration.topology - - for (const entry of candidate.signatureEntries.values()) { - if (isSapientSignatureType(entry.tags['Signature-Type'] as SignatureType)) { - const record = await this.loadRecord(entry) - signatures.set(sapientSignerKey(record.Signer, record['Image-Hash']), fromSapientSignatureRecord(record)) - } else { - const record = await this.loadRecord(entry) - signatures.set(signerKey(record.Signer), fromSignatureRecord(record)) - } - - topology = fillTopologyWithSignatures(configuration, signatures) - const { weight } = Config.getWeight(topology, () => false) - if (weight >= configuration.threshold) { - break - } - } - - const { weight } = Config.getWeight(topology, () => false) - if (weight < configuration.threshold) { - continue - } - - best = { - nextImageHash: candidate.nextImageHash, - checkpoint: candidate.checkpoint, - signature: { - noChainId: true, - configuration: { - threshold: configuration.threshold, - checkpoint: configuration.checkpoint, - checkpointer: configuration.checkpointer, - topology: toRecoveredLikeTopology(topology), - }, - }, - } - } - - if (!best) { - return [] - } - - const nextStep = await this.getConfigurationUpdates(normalizedWallet, best.nextImageHash, { allUpdates: true }) - return [{ imageHash: best.nextImageHash, signature: best.signature }, ...nextStep] } async getTree(imageHash: Hex.Hex): Promise { From f6be644b7bf6e7e8ee3fb156b382121a309abe88 Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 11:59:26 -0400 Subject: [PATCH 10/12] arweave state reader: getConfigurationUpdates based on keymachine implementation --- .../wallet/core/src/state/arweave/index.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index 98efe119a2..a341795f13 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -52,6 +52,7 @@ type WitnessMap = { type Candidate = { nextImageHash: Hex.Hex checkpoint: bigint + noChainId: boolean signatureEntries: Map } @@ -647,6 +648,147 @@ export class Reader implements ReaderInterface { fromImageHash: Hex.Hex, options?: { allUpdates?: boolean }, ): Promise> { + const normalizedWallet = normalizeAddress(wallet) + const signatureRecords = new Map>() + const loadSignatureRecord = (entry: ItemEntry): Promise => { + const cached = signatureRecords.get(entry.id) + if (cached) { + return cached + } + + const promise = isSapientSignatureType(entry.tags['Signature-Type'] as SignatureType) + ? this.loadRecord(entry) + : this.loadRecord(entry) + + signatureRecords.set(entry.id, promise) + return promise + } + + const updates: Array<{ imageHash: Hex.Hex; signature: Signature.RawSignature }> = [] + let currentImageHash = normalizeHex(fromImageHash) + + top: while (true) { + const currentConfig = await this.getConfiguration(currentImageHash) + if (!currentConfig) { + return updates + } + + const { signers, sapientSigners } = Config.getSigners(currentConfig) + const [plainEntries, sapientEntries] = await Promise.all([ + signers.length + ? this.findEntries({ + Type: 'config update', + Wallet: normalizedWallet, + Signer: signers.map(normalizeAddress), + 'Signature-Type': [...PLAIN_SIGNATURE_TYPES], + }) + : Promise.resolve([]), + Promise.all( + sapientSigners.map(({ address, imageHash }) => + this.findEntries({ + Type: 'config update', + Wallet: normalizedWallet, + Signer: normalizeAddress(address), + 'Image-Hash': normalizeHex(imageHash), + 'Signature-Type': [...SAPIENT_SIGNATURE_TYPES], + }), + ), + ), + ]) + + const candidates = new Map() + const addCandidate = (entry: ItemEntry, key: string) => { + const checkpoint = BigInt(entry.tags['To-Checkpoint']!) + if (checkpoint <= currentConfig.checkpoint) { + return + } + + const nextImageHash = normalizeHex(entry.tags['To-Config'] as Hex.Hex) + const candidateKey = `${checkpoint}:${nextImageHash.toLowerCase()}` + const candidate = candidates.get(candidateKey) + + if (candidate) { + if (!candidate.signatureEntries.has(key)) { + candidate.signatureEntries.set(key, entry) + } + + return + } + + candidates.set(candidateKey, { + nextImageHash, + checkpoint, + noChainId: entry.tags['Major-Version'] !== '1', + signatureEntries: new Map([[key, entry]]), + }) + } + + for (const entry of plainEntries) { + addCandidate(entry, signerKey(entry.tags.Signer as Address.Address)) + } + + for (const entries of sapientEntries) { + for (const entry of entries) { + addCandidate( + entry, + sapientSignerKey(entry.tags.Signer as Address.Address, entry.tags['Image-Hash'] as Hex.Hex), + ) + } + } + + const sortedCandidates = [...candidates.values()].sort((left, right) => { + if (left.checkpoint === right.checkpoint) { + return 0 + } + + if (options?.allUpdates) { + return left.checkpoint < right.checkpoint ? -1 : 1 + } + + return left.checkpoint > right.checkpoint ? -1 : 1 + }) + + for (const candidate of sortedCandidates) { + const signatures = new Map() + const records = await Promise.all([...candidate.signatureEntries.values()].map(loadSignatureRecord)) + + for (const record of records) { + if (isSapientSignatureType(record['Signature-Type'])) { + const sapientRecord = record as ArweaveSapientSignatureRecord + signatures.set( + sapientSignerKey(sapientRecord.Signer, sapientRecord['Image-Hash']), + fromSapientSignatureRecord(sapientRecord), + ) + } else { + signatures.set(signerKey(record.Signer), fromSignatureRecord(record as ArweaveSignatureRecord)) + } + } + + const topology = toRecoveredLikeTopology(fillTopologyWithSignatures(currentConfig, signatures)) + const { weight } = Config.getWeight(topology, () => false) + if (weight < currentConfig.threshold) { + continue + } + + updates.push({ + imageHash: candidate.nextImageHash, + signature: { + noChainId: candidate.noChainId, + configuration: { + threshold: currentConfig.threshold, + checkpoint: currentConfig.checkpoint, + checkpointer: currentConfig.checkpointer, + topology, + }, + }, + }) + + currentImageHash = candidate.nextImageHash + continue top + } + + return updates + } } async getTree(imageHash: Hex.Hex): Promise { From a0014824feb94692f7adfe2871567f03b1488e3f Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 12:11:12 -0400 Subject: [PATCH 11/12] prune config update signatures --- .../wallet/core/src/state/arweave/index.ts | 190 +++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index a341795f13..769f826737 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -56,6 +56,19 @@ type Candidate = { signatureEntries: Map } +type TopologyChoice = { + topology: Config.Topology + weight: bigint + signatures: number + size: number + signatureMask: string +} + +type TopologyChoiceSet = { + slotCount: number + choices: Map +} + const PLAIN_SIGNATURE_TYPES = ['eip-712', 'eth_sign', 'erc-1271'] satisfies SignatureType[] const SAPIENT_SIGNATURE_TYPES = ['sapient', 'sapient-compact'] satisfies SignatureType[] const PAYLOAD_VERSION_FILTER = { @@ -417,6 +430,175 @@ function fillTopologyWithSignatures( }) } +function clampWeight(weight: bigint, cap: bigint): bigint { + return weight > cap ? cap : weight +} + +function zeroMask(length: number): string { + return '0'.repeat(length) +} + +function compareChoices(left: TopologyChoice, right: TopologyChoice): number { + if (left.signatures !== right.signatures) { + return left.signatures - right.signatures + } + + if (left.size !== right.size) { + return left.size - right.size + } + + if (left.signatureMask !== right.signatureMask) { + return left.signatureMask > right.signatureMask ? -1 : 1 + } + + return 0 +} + +function dominatesChoice(left: TopologyChoice, right: TopologyChoice): boolean { + return ( + left.weight >= right.weight && + left.signatures <= right.signatures && + left.size <= right.size && + left.signatureMask >= right.signatureMask + ) +} + +function makeChoice( + topology: Config.Topology, + weight: bigint, + signatures: number, + signatureMask: string, +): TopologyChoice { + return { + topology, + weight, + signatures, + size: Signature.encodeTopology(topology).length, + signatureMask, + } +} + +function addChoice(choiceSet: TopologyChoiceSet, choice: TopologyChoice): void { + const key = choice.weight.toString() + const existing = choiceSet.choices.get(key) + + if (!existing || compareChoices(choice, existing) < 0) { + choiceSet.choices.set(key, choice) + } +} + +function pruneChoiceSet(choiceSet: TopologyChoiceSet): TopologyChoiceSet { + const choices = [...choiceSet.choices.values()] + const pruned = new Map() + + for (const candidate of choices) { + const dominated = choices.some((other) => other !== candidate && dominatesChoice(other, candidate)) + if (!dominated) { + pruned.set(candidate.weight.toString(), candidate) + } + } + + return { ...choiceSet, choices: pruned } +} + +function buildTopologyChoiceSet(topology: Config.Topology, cap: bigint): TopologyChoiceSet { + if (Signature.isSignedSignerLeaf(topology)) { + const choices: TopologyChoiceSet = { slotCount: 1, choices: new Map() } + addChoice( + choices, + makeChoice({ type: 'signer', address: topology.address, weight: topology.weight }, 0n, 0, '0'), + ) + + if (topology.weight > 0n) { + addChoice(choices, makeChoice(topology, clampWeight(topology.weight, cap), 1, '1')) + } + + return choices + } + + if (Signature.isSignedSapientSignerLeaf(topology)) { + const choices: TopologyChoiceSet = { slotCount: 1, choices: new Map() } + addChoice(choices, makeChoice(Hex.fromBytes(Config.hashConfiguration(topology)), 0n, 0, '0')) + + if (topology.weight > 0n) { + addChoice(choices, makeChoice(topology, clampWeight(topology.weight, cap), 1, '1')) + } + + return choices + } + + if (Config.isSignerLeaf(topology)) { + return { + slotCount: 0, + choices: new Map([[0n.toString(), makeChoice(topology, 0n, 0, '')]]), + } + } + + if (Config.isSapientSignerLeaf(topology)) { + return { + slotCount: 0, + choices: new Map([[0n.toString(), makeChoice(Hex.fromBytes(Config.hashConfiguration(topology)), 0n, 0, '')]]), + } + } + + if (Config.isSubdigestLeaf(topology) || Config.isAnyAddressSubdigestLeaf(topology) || Config.isNodeLeaf(topology)) { + return { + slotCount: 0, + choices: new Map([[0n.toString(), makeChoice(topology, 0n, 0, '')]]), + } + } + + if (Config.isNestedLeaf(topology)) { + const treeChoices = buildTopologyChoiceSet(topology.tree, topology.threshold) + const choices: TopologyChoiceSet = { slotCount: treeChoices.slotCount, choices: new Map() } + addChoice(choices, makeChoice(Hex.fromBytes(Config.hashConfiguration(topology)), 0n, 0, zeroMask(treeChoices.slotCount))) + + const satisfied = treeChoices.choices.get(topology.threshold.toString()) + if (satisfied && topology.weight > 0n) { + addChoice( + choices, + makeChoice( + { ...topology, tree: satisfied.topology }, + clampWeight(topology.weight, cap), + satisfied.signatures, + satisfied.signatureMask, + ), + ) + } + + return pruneChoiceSet(choices) + } + + const leftChoices = buildTopologyChoiceSet(topology[0], cap) + const rightChoices = buildTopologyChoiceSet(topology[1], cap) + const choices: TopologyChoiceSet = { + slotCount: leftChoices.slotCount + rightChoices.slotCount, + choices: new Map(), + } + + addChoice(choices, makeChoice(Hex.fromBytes(Config.hashConfiguration(topology)), 0n, 0, zeroMask(choices.slotCount))) + + for (const leftChoice of leftChoices.choices.values()) { + for (const rightChoice of rightChoices.choices.values()) { + addChoice( + choices, + makeChoice( + [leftChoice.topology, rightChoice.topology], + clampWeight(leftChoice.weight + rightChoice.weight, cap), + leftChoice.signatures + rightChoice.signatures, + `${leftChoice.signatureMask}${rightChoice.signatureMask}`, + ), + ) + } + } + + return pruneChoiceSet(choices) +} + +function minimizeTopologyForThreshold(topology: Config.Topology, threshold: bigint): Config.Topology | undefined { + return buildTopologyChoiceSet(topology, threshold).choices.get(threshold.toString())?.topology +} + export class Reader implements ReaderInterface { constructor(private readonly options: Options = defaults) {} @@ -764,7 +946,13 @@ export class Reader implements ReaderInterface { } } - const topology = toRecoveredLikeTopology(fillTopologyWithSignatures(currentConfig, signatures)) + const filledTopology = fillTopologyWithSignatures(currentConfig, signatures) + const minimalTopology = minimizeTopologyForThreshold(filledTopology, currentConfig.threshold) + if (!minimalTopology) { + continue + } + + const topology = toRecoveredLikeTopology(minimalTopology) const { weight } = Config.getWeight(topology, () => false) if (weight < currentConfig.threshold) { continue From c57769889acb5742fc8e5cd26fbcb6be246308a7 Mon Sep 17 00:00:00 2001 From: William Hua Date: Wed, 15 Apr 2026 12:19:06 -0400 Subject: [PATCH 12/12] rm isPlainSignatureType --- packages/wallet/core/src/state/arweave/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/wallet/core/src/state/arweave/index.ts b/packages/wallet/core/src/state/arweave/index.ts index 769f826737..e0d05412d9 100644 --- a/packages/wallet/core/src/state/arweave/index.ts +++ b/packages/wallet/core/src/state/arweave/index.ts @@ -76,10 +76,6 @@ const PAYLOAD_VERSION_FILTER = { 'Minor-Version': '2', } as const -function isPlainSignatureType(type: SignatureType): type is (typeof PLAIN_SIGNATURE_TYPES)[number] { - return (PLAIN_SIGNATURE_TYPES as readonly SignatureType[]).includes(type) -} - function isSapientSignatureType(type: SignatureType): type is (typeof SAPIENT_SIGNATURE_TYPES)[number] { return (SAPIENT_SIGNATURE_TYPES as readonly SignatureType[]).includes(type) }