From a24bb60a275860ff9d800df81eb0fe6cf7614090 Mon Sep 17 00:00:00 2001 From: William Hua Date: Tue, 14 Apr 2026 14:42:03 -0400 Subject: [PATCH 1/2] primitives: minimiseSignedTopology --- packages/wallet/core/src/state/local/index.ts | 4 +- .../wallet/core/src/state/sequence/index.ts | 3 +- packages/wallet/primitives/src/config.ts | 83 +++++++++++++++++ .../wallet/primitives/test/config.test.ts | 93 +++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/packages/wallet/core/src/state/local/index.ts b/packages/wallet/core/src/state/local/index.ts index 282de20e0..ed92d8c18 100644 --- a/packages/wallet/core/src/state/local/index.ts +++ b/packages/wallet/core/src/state/local/index.ts @@ -313,6 +313,8 @@ export class Provider implements ProviderInterface { continue } + const minimalTopology = Config.minimiseSignedTopology(encoded, fromConfig.threshold) + best = { nextImageHash: candidate.nextImageHash, checkpoint: candidate.config!.checkpoint, @@ -321,7 +323,7 @@ export class Provider implements ProviderInterface { configuration: { threshold: fromConfig.threshold, checkpoint: fromConfig.checkpoint, - topology: encoded, + topology: minimalTopology, }, }, } diff --git a/packages/wallet/core/src/state/sequence/index.ts b/packages/wallet/core/src/state/sequence/index.ts index 084211153..68480b041 100644 --- a/packages/wallet/core/src/state/sequence/index.ts +++ b/packages/wallet/core/src/state/sequence/index.ts @@ -249,8 +249,9 @@ export class Provider implements ProviderInterface { const { configuration } = await Signature.recover(decoded, wallet, 0, Payload.fromConfigUpdate(toImageHash), { provider: passkeySignatureValidator, }) + const topology = Config.minimiseSignedTopology(configuration.topology, configuration.threshold) - return { imageHash: toImageHash, signature: { ...decoded, configuration } } + return { imageHash: toImageHash, signature: { ...decoded, configuration: { ...configuration, topology } } } }), ) } diff --git a/packages/wallet/primitives/src/config.ts b/packages/wallet/primitives/src/config.ts index 20c54dc49..48c352050 100644 --- a/packages/wallet/primitives/src/config.ts +++ b/packages/wallet/primitives/src/config.ts @@ -240,6 +240,89 @@ export function getWeight( } } +type MinimisedTopologyPlan = { + weight: bigint + topology: Topology +} + +function stripSignedState(leaf: SignerLeaf | SapientSignerLeaf): SignerLeaf | SapientSignerLeaf { + const { signed: _signed, signature: _signature, ...rest } = leaf + return rest +} + +function buildMinimisedTopologyPlans(topology: Topology): Array { + if (isSignedSignerLeaf(topology) || isSignedSapientSignerLeaf(topology)) { + return [ + { weight: 0n, topology: stripSignedState(topology) }, + { weight: topology.weight, topology }, + ] + } + + if ( + isSignerLeaf(topology) || + isSapientSignerLeaf(topology) || + isSubdigestLeaf(topology) || + isAnyAddressSubdigestLeaf(topology) || + isNodeLeaf(topology) + ) { + return [{ weight: 0n, topology }] + } + + if (isNestedLeaf(topology)) { + return buildMinimisedTopologyPlans(topology.tree).map((plan) => { + if (!plan) { + return undefined + } + + return { + weight: plan.weight >= topology.threshold ? topology.weight : 0n, + topology: { + ...topology, + tree: plan.topology, + }, + } + }) + } + + if (isNode(topology)) { + const leftPlans = buildMinimisedTopologyPlans(topology[0]) + const rightPlans = buildMinimisedTopologyPlans(topology[1]) + const plans = new Array(leftPlans.length + rightPlans.length - 1) + + for (let total = 0; total < plans.length; total++) { + const maxLeft = Math.min(total, leftPlans.length - 1) + const minLeft = Math.max(0, total - (rightPlans.length - 1)) + + // Iterate from right to left so earlier topology positions win ties. + for (let leftCount = maxLeft; leftCount >= minLeft; leftCount--) { + const leftPlan = leftPlans[leftCount] + const rightPlan = rightPlans[total - leftCount] + + if (!leftPlan || !rightPlan) { + continue + } + + const weight = leftPlan.weight + rightPlan.weight + if (!plans[total] || weight > plans[total]!.weight) { + plans[total] = { + weight, + topology: [leftPlan.topology, rightPlan.topology], + } + } + } + } + + return plans + } + + throw new Error('Invalid topology') +} + +export function minimiseSignedTopology(topology: Topology, threshold: bigint): Topology { + const plans = buildMinimisedTopologyPlans(topology) + return plans.find((plan) => plan && plan.weight >= threshold)?.topology ?? topology +} + export function hashConfiguration(topology: Topology | Config): Bytes.Bytes { if (isConfig(topology)) { let root = hashConfiguration(topology.topology) diff --git a/packages/wallet/primitives/test/config.test.ts b/packages/wallet/primitives/test/config.test.ts index b4ff8e6d9..03c00ad73 100644 --- a/packages/wallet/primitives/test/config.test.ts +++ b/packages/wallet/primitives/test/config.test.ts @@ -24,6 +24,7 @@ import { getSigners, findSignerLeaf, getWeight, + minimiseSignedTopology, hashConfiguration, flatLeavesToTopology, configToJson, @@ -91,6 +92,37 @@ describe('Config', () => { checkpointer: testAddress1, } + function signedAddresses(topology: Topology): string[] { + if (isNode(topology)) { + return [...signedAddresses(topology[0]), ...signedAddresses(topology[1])] + } + + if (isNestedLeaf(topology)) { + return signedAddresses(topology.tree) + } + + if ((isSignerLeaf(topology) || isSapientSignerLeaf(topology)) && topology.signature) { + return [topology.address] + } + + return [] + } + + function signSigner(address: string, weight: bigint, nonce: bigint): SignerLeaf { + return { + type: 'signer', + address, + weight, + signed: true, + signature: { + type: 'hash', + r: nonce, + s: nonce + 1n, + yParity: 0, + }, + } + } + describe('Type Guards', () => { describe('isSignerLeaf', () => { it('should return true for valid signer leaf', () => { @@ -417,6 +449,67 @@ describe('Config', () => { }) }) + describe('minimiseSignedTopology', () => { + it('should prefer the smallest signature count that still meets threshold', () => { + const topology = flatLeavesToTopology([ + signSigner('0x1000000000000000000000000000000000000001', 4n, 1n), + signSigner('0x1000000000000000000000000000000000000002', 4n, 3n), + signSigner('0x1000000000000000000000000000000000000003', 4n, 5n), + signSigner('0x1000000000000000000000000000000000000004', 6n, 7n), + signSigner('0x1000000000000000000000000000000000000005', 6n, 9n), + ]) + + const result = minimiseSignedTopology(topology, 12n) + + expect(signedAddresses(result)).toEqual([ + '0x1000000000000000000000000000000000000004', + '0x1000000000000000000000000000000000000005', + ]) + expect(getWeight(result, () => false).weight).toBe(12n) + }) + + it('should keep earlier signers when equal-count solutions tie', () => { + const topology = flatLeavesToTopology([ + signSigner('0x2000000000000000000000000000000000000001', 1n, 11n), + signSigner('0x2000000000000000000000000000000000000002', 1n, 13n), + signSigner('0x2000000000000000000000000000000000000003', 1n, 15n), + ]) + + const result = minimiseSignedTopology(topology, 2n) + + expect(signedAddresses(result)).toEqual([ + '0x2000000000000000000000000000000000000001', + '0x2000000000000000000000000000000000000002', + ]) + }) + + it('should minimise nested signers while preserving nested thresholds', () => { + const nested: NestedLeaf = { + type: 'nested', + weight: 4n, + threshold: 2n, + tree: flatLeavesToTopology([ + signSigner('0x3000000000000000000000000000000000000001', 1n, 21n), + signSigner('0x3000000000000000000000000000000000000002', 1n, 23n), + signSigner('0x3000000000000000000000000000000000000003', 1n, 25n), + ]), + } + const topology: Topology = [ + nested, + signSigner('0x3000000000000000000000000000000000000004', 3n, 27n), + ] + + const result = minimiseSignedTopology(topology, 5n) + + expect(signedAddresses(result)).toEqual([ + '0x3000000000000000000000000000000000000001', + '0x3000000000000000000000000000000000000002', + '0x3000000000000000000000000000000000000004', + ]) + expect(getWeight(result, () => false).weight).toBe(7n) + }) + }) + describe('hashConfiguration', () => { it('should hash signer leaf correctly', () => { const hash = hashConfiguration(sampleSignerLeaf) From d8e5b96518cd3351377b939e2f9ff36c362db454 Mon Sep 17 00:00:00 2001 From: William Hua Date: Tue, 14 Apr 2026 16:32:38 -0400 Subject: [PATCH 2/2] fix(wallet): minimize config update topologies with subdigest weight --- packages/wallet/core/src/state/local/index.ts | 12 ++- .../wallet/core/src/state/sequence/index.ts | 9 +- packages/wallet/primitives/src/config.ts | 90 +++++++++++++++---- packages/wallet/primitives/src/signature.ts | 9 +- .../wallet/primitives/test/config.test.ts | 55 ++++++++++++ 5 files changed, 146 insertions(+), 29 deletions(-) diff --git a/packages/wallet/core/src/state/local/index.ts b/packages/wallet/core/src/state/local/index.ts index ed92d8c18..b74963520 100644 --- a/packages/wallet/core/src/state/local/index.ts +++ b/packages/wallet/core/src/state/local/index.ts @@ -285,7 +285,6 @@ export class Provider implements ProviderInterface { }), ]) - let totalWeight = 0n const encoded = Signature.fillLeaves(fromConfig.topology, (leaf) => { if (Config.isSapientSignerLeaf(leaf)) { const sapientSignature = signaturesOfSigners.find( @@ -295,7 +294,6 @@ export class Provider implements ProviderInterface { )?.signature if (sapientSignature) { - totalWeight += leaf.weight return sapientSignature } } @@ -305,15 +303,21 @@ export class Provider implements ProviderInterface { return undefined } - totalWeight += leaf.weight return signature }) + const topologyContext: Config.TopologyWeightContext = { + wallet, + chainId: candidate.payload.chainId, + payload: candidate.payload.content, + } + const { weight: totalWeight } = Config.getWeight(encoded, () => false, topologyContext) + if (totalWeight < fromConfig.threshold) { continue } - const minimalTopology = Config.minimiseSignedTopology(encoded, fromConfig.threshold) + const minimalTopology = Config.minimiseSignedTopology(encoded, fromConfig.threshold, topologyContext) best = { nextImageHash: candidate.nextImageHash, diff --git a/packages/wallet/core/src/state/sequence/index.ts b/packages/wallet/core/src/state/sequence/index.ts index 68480b041..7609b4c43 100644 --- a/packages/wallet/core/src/state/sequence/index.ts +++ b/packages/wallet/core/src/state/sequence/index.ts @@ -244,12 +244,17 @@ export class Provider implements ProviderInterface { Hex.assert(toImageHash) Hex.assert(signature) + const payload = Payload.fromConfigUpdate(toImageHash) const decoded = Signature.decodeSignature(Hex.toBytes(signature)) - const { configuration } = await Signature.recover(decoded, wallet, 0, Payload.fromConfigUpdate(toImageHash), { + const { configuration } = await Signature.recover(decoded, wallet, 0, payload, { provider: passkeySignatureValidator, }) - const topology = Config.minimiseSignedTopology(configuration.topology, configuration.threshold) + const topology = Config.minimiseSignedTopology(configuration.topology, configuration.threshold, { + wallet, + chainId: 0, + payload, + }) return { imageHash: toImageHash, signature: { ...decoded, configuration: { ...configuration, topology } } } }), diff --git a/packages/wallet/primitives/src/config.ts b/packages/wallet/primitives/src/config.ts index 48c352050..54cc19b75 100644 --- a/packages/wallet/primitives/src/config.ts +++ b/packages/wallet/primitives/src/config.ts @@ -10,6 +10,8 @@ import { SignatureOfSapientSignerLeaf, SignatureOfSignerLeaf, } from './signature.js' +import { hash as hashPayload } from './payload.js' +import type { Parented } from './payload.js' import { Constants } from './index.js' export type SignerLeaf = { @@ -104,6 +106,19 @@ export type Config = { checkpointer?: Address.Address } +export type TopologyWeightContext = { + wallet: Address.Address + chainId: number + payload: Parented +} + +export const MATCHING_SUBDIGEST_WEIGHT = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn + +type ResolvedTopologyWeightContext = { + digest: Bytes.Bytes + anyAddressDigest: Bytes.Bytes +} + export function isSignerLeaf(cand: unknown): cand is SignerLeaf { return typeof cand === 'object' && cand !== null && 'type' in cand && cand.type === 'signer' } @@ -209,6 +224,41 @@ export function findSignerLeaf( export function getWeight( topology: RawTopology | RawConfig | Config, canSign: (signer: SignerLeaf | SapientSignerLeaf) => boolean, + context?: TopologyWeightContext, +): { weight: bigint; maxWeight: bigint } { + return getWeightWithContext(topology, canSign, resolveTopologyWeightContext(context)) +} + +function resolveTopologyWeightContext(context?: TopologyWeightContext): ResolvedTopologyWeightContext | undefined { + if (!context) { + return undefined + } + + return { + digest: hashPayload(context.wallet, context.chainId, context.payload), + anyAddressDigest: hashPayload(Constants.ZeroAddress, context.chainId, context.payload), + } +} + +function getSubdigestWeight( + topology: SubdigestLeaf | AnyAddressSubdigestLeaf, + context?: ResolvedTopologyWeightContext, +): bigint { + if (!context) { + return 0n + } + + if (isSubdigestLeaf(topology)) { + return Bytes.isEqual(Bytes.fromHex(topology.digest), context.digest) ? MATCHING_SUBDIGEST_WEIGHT : 0n + } + + return Bytes.isEqual(Bytes.fromHex(topology.digest), context.anyAddressDigest) ? MATCHING_SUBDIGEST_WEIGHT : 0n +} + +function getWeightWithContext( + topology: RawTopology | RawConfig | Config, + canSign: (signer: SignerLeaf | SapientSignerLeaf) => boolean, + context?: ResolvedTopologyWeightContext, ): { weight: bigint; maxWeight: bigint } { topology = isRawConfig(topology) || isConfig(topology) ? topology.topology : topology @@ -223,11 +273,13 @@ export function getWeight( } else if (isSapientSignerLeaf(topology)) { return { weight: 0n, maxWeight: canSign(topology) ? topology.weight : 0n } } else if (isSubdigestLeaf(topology)) { - return { weight: 0n, maxWeight: 0n } + const weight = getSubdigestWeight(topology, context) + return { weight, maxWeight: weight } } else if (isAnyAddressSubdigestLeaf(topology)) { - return { weight: 0n, maxWeight: 0n } + const weight = getSubdigestWeight(topology, context) + return { weight, maxWeight: weight } } else if (isRawNestedLeaf(topology)) { - const { weight, maxWeight } = getWeight(topology.tree, canSign) + const { weight, maxWeight } = getWeightWithContext(topology.tree, canSign, context) return { weight: weight >= topology.threshold ? topology.weight : 0n, maxWeight: maxWeight >= topology.threshold ? topology.weight : 0n, @@ -235,7 +287,10 @@ export function getWeight( } else if (isNodeLeaf(topology)) { return { weight: 0n, maxWeight: 0n } } else { - const [left, right] = [getWeight(topology[0], canSign), getWeight(topology[1], canSign)] + const [left, right] = [ + getWeightWithContext(topology[0], canSign, context), + getWeightWithContext(topology[1], canSign, context), + ] return { weight: left.weight + right.weight, maxWeight: left.maxWeight + right.maxWeight } } } @@ -250,7 +305,10 @@ function stripSignedState(leaf: SignerLeaf | SapientSignerLeaf): SignerLeaf | Sa return rest } -function buildMinimisedTopologyPlans(topology: Topology): Array { +function buildMinimisedTopologyPlans( + topology: Topology, + context?: ResolvedTopologyWeightContext, +): Array { if (isSignedSignerLeaf(topology) || isSignedSapientSignerLeaf(topology)) { return [ { weight: 0n, topology: stripSignedState(topology) }, @@ -258,18 +316,16 @@ function buildMinimisedTopologyPlans(topology: Topology): Array { + return buildMinimisedTopologyPlans(topology.tree, context).map((plan) => { if (!plan) { return undefined } @@ -285,8 +341,8 @@ function buildMinimisedTopologyPlans(topology: Topology): Array(leftPlans.length + rightPlans.length - 1) for (let total = 0; total < plans.length; total++) { @@ -318,8 +374,8 @@ function buildMinimisedTopologyPlans(topology: Topology): Array plan && plan.weight >= threshold)?.topology ?? topology } diff --git a/packages/wallet/primitives/src/signature.ts b/packages/wallet/primitives/src/signature.ts index 6ba93f9c1..424a440ec 100644 --- a/packages/wallet/primitives/src/signature.ts +++ b/packages/wallet/primitives/src/signature.ts @@ -7,6 +7,7 @@ import { SignerLeaf, SubdigestLeaf, AnyAddressSubdigestLeaf, + MATCHING_SUBDIGEST_WEIGHT, Topology, hashConfiguration, isNestedLeaf, @@ -1312,17 +1313,13 @@ async function recoverTopology( } else if (isSubdigestLeaf(topology)) { return { topology, - weight: Bytes.isEqual(Bytes.fromHex(topology.digest), digest) - ? 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn - : 0n, + weight: Bytes.isEqual(Bytes.fromHex(topology.digest), digest) ? MATCHING_SUBDIGEST_WEIGHT : 0n, } } else if (isAnyAddressSubdigestLeaf(topology)) { const anyAddressOpHash = hash(Constants.ZeroAddress, chainId, payload) return { topology, - weight: Bytes.isEqual(Bytes.fromHex(topology.digest), anyAddressOpHash) - ? 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn - : 0n, + weight: Bytes.isEqual(Bytes.fromHex(topology.digest), anyAddressOpHash) ? MATCHING_SUBDIGEST_WEIGHT : 0n, } } else if (isNodeLeaf(topology)) { return { topology, weight: 0n } diff --git a/packages/wallet/primitives/test/config.test.ts b/packages/wallet/primitives/test/config.test.ts index 03c00ad73..d1c30f24e 100644 --- a/packages/wallet/primitives/test/config.test.ts +++ b/packages/wallet/primitives/test/config.test.ts @@ -24,6 +24,7 @@ import { getSigners, findSignerLeaf, getWeight, + MATCHING_SUBDIGEST_WEIGHT, minimiseSignedTopology, hashConfiguration, flatLeavesToTopology, @@ -36,6 +37,7 @@ import { normalizeSignerSignature, replaceAddress, } from '../src/config.js' +import { fromConfigUpdate, hash as hashPayload } from '../src/payload.js' describe('Config', () => { const testAddress1 = '0x742d35cc6635c0532925a3b8d563a6b35b7f05f1' @@ -508,6 +510,59 @@ describe('Config', () => { ]) expect(getWeight(result, () => false).weight).toBe(7n) }) + + it('should strip signer signatures when a matching subdigest satisfies the threshold', () => { + const payload = fromConfigUpdate( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as `0x${string}`, + ) + const topology = flatLeavesToTopology([ + { + type: 'subdigest', + digest: Bytes.toHex(hashPayload(testAddress1, 0, payload)) as `0x${string}`, + }, + signSigner('0x4000000000000000000000000000000000000001', 1n, 31n), + signSigner('0x4000000000000000000000000000000000000002', 1n, 33n), + ]) + + expect(signedAddresses(minimiseSignedTopology(topology, 1n))).toEqual([ + '0x4000000000000000000000000000000000000001', + ]) + + const result = minimiseSignedTopology(topology, 1n, { + wallet: testAddress1, + chainId: 0, + payload, + }) + + expect(signedAddresses(result)).toEqual([]) + expect(getWeight(result, () => false, { wallet: testAddress1, chainId: 0, payload }).weight).toBe( + MATCHING_SUBDIGEST_WEIGHT, + ) + }) + + it('should strip signer signatures when a matching any-address subdigest satisfies the threshold', () => { + const payload = fromConfigUpdate( + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as `0x${string}`, + ) + const topology = flatLeavesToTopology([ + { + type: 'any-address-subdigest', + digest: Bytes.toHex( + hashPayload('0x0000000000000000000000000000000000000000', 0, payload), + ) as `0x${string}`, + }, + signSigner('0x5000000000000000000000000000000000000001', 1n, 41n), + signSigner('0x5000000000000000000000000000000000000002', 1n, 43n), + ]) + + const result = minimiseSignedTopology(topology, 1n, { + wallet: testAddress1, + chainId: 0, + payload, + }) + + expect(signedAddresses(result)).toEqual([]) + }) }) describe('hashConfiguration', () => {