From 0aff75e9d77668cb97afca37750bf29a9639b150 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 7 Jul 2026 14:52:12 -0600 Subject: [PATCH 1/2] feat(0.19): support tnt-core 0.19 behind the per-chain revision flag (staggered) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-synced ABIs to tnt-core 0.19 and made every 0.19 break site revision-aware so 0.18 and 0.19 both work per-chain — NO hard cutover, no chain flipped to v019 yet. - dapp-config: add 'v019' to TntCoreRevision (getTntCoreRevisionByChainId stays 'legacy'). - abi/*: re-synced from canonical tnt-core 0.19 (blueprintSources/canScheduleExit/reportUri/ disputeReason gone; shorter getSlashProposal tuple). - useBinaryVersions: BinaryVersion.binaryUri / Attestation.reportUri -> string | null; v019 read ABIs (URI slots dropped); URI null on v019 (indexer-sourced follow-up). - useSlashing: v018 vs v019 getSlashProposal tuple ABIs + revision branch in the normalizer (proposedAt/disputeReason/disputedAt default to 0/null on v019); buildSlashTimeline tolerates proposedAt=0. - useCanScheduleExit: legacy direct-read kept; v019 fail-closed default. - readBlueprintCore: dedicated legacy getBlueprint ABI (0.19 dropped operatorCount from it). - UI: null binaryUri/reportUri render a graceful "unavailable on this chain". Write paths (publishBinaryVersion/attestBinaryVersion still take the URIs as params) unchanged. Verified: nx typecheck tangle-dapp + tangle-cloud pass; trustScore tests 11/11. --- .../binaryUpgrade/BlueprintVersionsPanel.tsx | 7 +- libs/dapp-config/src/contracts.ts | 16 +- .../src/abi/blueprintServiceManager.ts | 16 +- .../src/abi/multiAssetDelegation.ts | 2 +- .../src/abi/operatorStatusRegistry.ts | 90 +++ libs/tangle-shared-ui/src/abi/tangle.ts | 585 ++++++++++++------ .../src/abi/tangleBinaryUpgrade.ts | 108 ++++ .../src/blueprintApps/trustScore.ts | 5 +- .../src/data/blueprints/readBlueprintCore.ts | 53 +- .../src/data/blueprints/useBinaryVersions.ts | 50 +- .../src/data/graphql/useSlashing.ts | 146 ++++- .../src/data/services/useCanScheduleExit.ts | 43 +- 12 files changed, 864 insertions(+), 257 deletions(-) diff --git a/apps/tangle-cloud/src/components/binaryUpgrade/BlueprintVersionsPanel.tsx b/apps/tangle-cloud/src/components/binaryUpgrade/BlueprintVersionsPanel.tsx index 7cab4cfafd..710a42c987 100644 --- a/apps/tangle-cloud/src/components/binaryUpgrade/BlueprintVersionsPanel.tsx +++ b/apps/tangle-cloud/src/components/binaryUpgrade/BlueprintVersionsPanel.tsx @@ -458,7 +458,12 @@ const VersionRow: FC = ({ {version.binaryUri} ) : ( - '—' + // tnt-core 0.19 no longer returns `binaryUri` on-chain — it + // is event-sourced. Until the indexer wiring lands it reads + // `null` here; show a graceful notice instead of a bare dash. + + (unavailable on this chain) + ) } /> diff --git a/libs/dapp-config/src/contracts.ts b/libs/dapp-config/src/contracts.ts index b725f3677d..7c8b0d0d81 100644 --- a/libs/dapp-config/src/contracts.ts +++ b/libs/dapp-config/src/contracts.ts @@ -145,11 +145,21 @@ export const ETHEREUM_HOLESKY_CONTRACTS: ContractAddresses = { * legacy 7-field tuple throws), display prose is event-sourced from * `BlueprintDefinitionRecorded`, and the `LockInfo` slot named * `expiryBlock` carries a unix TIMESTAMP. + * - `v019`: tnt-core 0.19 hash+emit shrink — several on-chain reads lost + * storage fields that are now event-sourced. The `getSlashProposal` tuple + * dropped `proposedAt` / `disputeReason` / `disputedAt`; the binary-version + * read (`getBinaryVersion` / `effectiveBinaryVersion`) dropped `binaryUri`; + * the attestation read (`getAttestation` / `listAttestations`) dropped + * `reportUri`; and the `canScheduleExit(uint64,address)` view was removed + * (derive from `getExitStatus`). Publish/attest WRITE params are unchanged — + * only the stored/returned fields moved off-chain. Decoding a v0.18 tuple + * against v0.19 returndata throws, so these reads must select the shorter + * ABI by revision; do not merge the two entries into one ABI. * - * Flip a chain to `v018` in the same change that updates its addresses to - * the redeployed contracts — never separately. + * Flip a chain to `v018` / `v019` in the same change that updates its + * addresses to the redeployed contracts — never separately. */ -export type TntCoreRevision = 'legacy' | 'v018'; +export type TntCoreRevision = 'legacy' | 'v018' | 'v019'; export const getTntCoreRevisionByChainId = ( chainId: number, diff --git a/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts b/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts index efa42bdaaf..ed008fa6d5 100644 --- a/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts +++ b/libs/tangle-shared-ui/src/abi/blueprintServiceManager.ts @@ -387,11 +387,6 @@ const ABI = [ type: 'bytes32', internalType: 'bytes32', }, - { - name: 'binaryUri', - type: 'string', - internalType: 'string', - }, { name: 'attestationHash', type: 'bytes32', @@ -409,6 +404,11 @@ const ABI = [ }, ], }, + { + name: 'binaryUri', + type: 'string', + internalType: 'string', + }, ], outputs: [], stateMutability: 'nonpayable', @@ -530,9 +530,9 @@ const ABI = [ internalType: 'address', }, { - name: 'inputs', - type: 'bytes', - internalType: 'bytes', + name: 'inputsHash', + type: 'bytes32', + internalType: 'bytes32', }, { name: 'outputs', diff --git a/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts b/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts index 1629aab048..8851d80918 100644 --- a/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts +++ b/libs/tangle-shared-ui/src/abi/multiAssetDelegation.ts @@ -831,7 +831,7 @@ const ABI = [ internalType: 'enum Types.LockMultiplier', }, { - name: 'expiryBlock', + name: 'expiryTimestamp', type: 'uint64', internalType: 'uint64', }, diff --git a/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts b/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts index ae58b383a7..7c4806e658 100644 --- a/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts +++ b/libs/tangle-shared-ui/src/abi/operatorStatusRegistry.ts @@ -1530,6 +1530,11 @@ const ABI = [ ], anonymous: false, }, + { + type: 'error', + name: 'AlreadyRegistered', + inputs: [], + }, { type: 'error', name: 'ECDSAInvalidSignature', @@ -1589,6 +1594,81 @@ const ABI = [ }, ], }, + { + type: 'error', + name: 'InternalOnly', + inputs: [], + }, + { + type: 'error', + name: 'IntervalTooShort', + inputs: [], + }, + { + type: 'error', + name: 'InvalidBounds', + inputs: [], + }, + { + type: 'error', + name: 'InvalidMaxMissed', + inputs: [], + }, + { + type: 'error', + name: 'InvalidSignature', + inputs: [], + }, + { + type: 'error', + name: 'NameTooLong', + inputs: [], + }, + { + type: 'error', + name: 'NotAuthorized', + inputs: [], + }, + { + type: 'error', + name: 'NotRegistered', + inputs: [], + }, + { + type: 'error', + name: 'NotRegisteredOperator', + inputs: [], + }, + { + type: 'error', + name: 'NotServiceOwner', + inputs: [], + }, + { + type: 'error', + name: 'NotSlashingOracle', + inputs: [], + }, + { + type: 'error', + name: 'OnlyTangleCore', + inputs: [], + }, + { + type: 'error', + name: 'OperatorNotEligibleForRemoval', + inputs: [], + }, + { + type: 'error', + name: 'OperatorSlashed', + inputs: [], + }, + { + type: 'error', + name: 'OperatorUnknown', + inputs: [], + }, { type: 'error', name: 'OwnableInvalidOwner', @@ -1611,6 +1691,16 @@ const ABI = [ }, ], }, + { + type: 'error', + name: 'TooManyDefinitions', + inputs: [], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, ] as const; export default ABI; diff --git a/libs/tangle-shared-ui/src/abi/tangle.ts b/libs/tangle-shared-ui/src/abi/tangle.ts index cc2c5b4f2c..d477f07c26 100644 --- a/libs/tangle-shared-ui/src/abi/tangle.ts +++ b/libs/tangle-shared-ui/src/abi/tangle.ts @@ -1,5 +1,36 @@ // AUTO-GENERATED FROM tnt-core. DO NOT EDIT MANUALLY. const ABI = [ + { + type: 'function', + name: 'acceptBlueprintOwnership', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'ackBlueprintSources', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'sourcesHash', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'addPermittedCaller', @@ -178,6 +209,25 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'blueprintDefinitionHash', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'blueprintMasterRevision', @@ -294,7 +344,7 @@ const ABI = [ }, { type: 'function', - name: 'blueprintSources', + name: 'blueprintSourcesHash', inputs: [ { name: 'blueprintId', @@ -304,136 +354,9 @@ const ABI = [ ], outputs: [ { - name: 'sources', - type: 'tuple[]', - internalType: 'struct Types.BlueprintSource[]', - components: [ - { - name: 'kind', - type: 'uint8', - internalType: 'enum Types.BlueprintSourceKind', - }, - { - name: 'container', - type: 'tuple', - internalType: 'struct Types.ImageRegistrySource', - components: [ - { - name: 'registry', - type: 'string', - internalType: 'string', - }, - { - name: 'image', - type: 'string', - internalType: 'string', - }, - { - name: 'tag', - type: 'string', - internalType: 'string', - }, - ], - }, - { - name: 'wasm', - type: 'tuple', - internalType: 'struct Types.WasmSource', - components: [ - { - name: 'runtime', - type: 'uint8', - internalType: 'enum Types.WasmRuntime', - }, - { - name: 'fetcher', - type: 'uint8', - internalType: 'enum Types.BlueprintFetcherKind', - }, - { - name: 'artifactUri', - type: 'string', - internalType: 'string', - }, - { - name: 'entrypoint', - type: 'string', - internalType: 'string', - }, - ], - }, - { - name: 'native', - type: 'tuple', - internalType: 'struct Types.NativeSource', - components: [ - { - name: 'fetcher', - type: 'uint8', - internalType: 'enum Types.BlueprintFetcherKind', - }, - { - name: 'artifactUri', - type: 'string', - internalType: 'string', - }, - { - name: 'entrypoint', - type: 'string', - internalType: 'string', - }, - ], - }, - { - name: 'testing', - type: 'tuple', - internalType: 'struct Types.TestingSource', - components: [ - { - name: 'cargoPackage', - type: 'string', - internalType: 'string', - }, - { - name: 'cargoBin', - type: 'string', - internalType: 'string', - }, - { - name: 'basePath', - type: 'string', - internalType: 'string', - }, - ], - }, - { - name: 'binaries', - type: 'tuple[]', - internalType: 'struct Types.BlueprintBinary[]', - components: [ - { - name: 'arch', - type: 'uint8', - internalType: 'enum Types.BlueprintArchitecture', - }, - { - name: 'os', - type: 'uint8', - internalType: 'enum Types.BlueprintOperatingSystem', - }, - { - name: 'name', - type: 'string', - internalType: 'string', - }, - { - name: 'sha256', - type: 'bytes32', - internalType: 'bytes32', - }, - ], - }, - ], + name: '', + type: 'bytes32', + internalType: 'bytes32', }, ], stateMutability: 'view', @@ -459,32 +382,16 @@ const ABI = [ }, { type: 'function', - name: 'canScheduleExit', + name: 'cancelBlueprintTransfer', inputs: [ { - name: 'serviceId', + name: 'blueprintId', type: 'uint64', internalType: 'uint64', }, - { - name: 'operator', - type: 'address', - internalType: 'address', - }, - ], - outputs: [ - { - name: 'canExit', - type: 'bool', - internalType: 'bool', - }, - { - name: 'reason', - type: 'string', - internalType: 'string', - }, ], - stateMutability: 'view', + outputs: [], + stateMutability: 'nonpayable', }, { type: 'function', @@ -937,6 +844,16 @@ const ABI = [ type: 'uint8', internalType: 'enum Types.ConfidentialityPolicy', }, + { + name: 'operation', + type: 'uint8', + internalType: 'enum Types.QuoteOperation', + }, + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, { name: 'securityCommitments', type: 'tuple[]', @@ -1189,6 +1106,16 @@ const ABI = [ type: 'uint8', internalType: 'enum Types.ConfidentialityPolicy', }, + { + name: 'operation', + type: 'uint8', + internalType: 'enum Types.QuoteOperation', + }, + { + name: 'serviceId', + type: 'uint64', + internalType: 'uint64', + }, { name: 'securityCommitments', type: 'tuple[]', @@ -1362,11 +1289,6 @@ const ABI = [ type: 'uint64', internalType: 'uint64', }, - { - name: 'operatorCount', - type: 'uint32', - internalType: 'uint32', - }, { name: 'membership', type: 'uint8', @@ -2171,21 +2093,6 @@ const ABI = [ type: 'uint64', internalType: 'uint64', }, - { - name: 'updatedAt', - type: 'uint64', - internalType: 'uint64', - }, - { - name: 'active', - type: 'bool', - internalType: 'bool', - }, - { - name: 'online', - type: 'bool', - internalType: 'bool', - }, ], }, ], @@ -2841,11 +2748,6 @@ const ABI = [ type: 'bytes32', internalType: 'bytes32', }, - { - name: 'proposedAt', - type: 'uint64', - internalType: 'uint64', - }, { name: 'executeAfter', type: 'uint64', @@ -2856,11 +2758,6 @@ const ABI = [ type: 'uint8', internalType: 'enum SlashingLib.SlashStatus', }, - { - name: 'disputeReason', - type: 'string', - internalType: 'string', - }, { name: 'disputer', type: 'address', @@ -2871,11 +2768,6 @@ const ABI = [ type: 'uint256', internalType: 'uint256', }, - { - name: 'disputedAt', - type: 'uint64', - internalType: 'uint64', - }, { name: 'disputeDeadline', type: 'uint64', @@ -3081,27 +2973,40 @@ const ABI = [ }, { type: 'function', - name: 'maxBlueprintsPerOperator', + name: 'managerHookGasLimit', inputs: [], outputs: [ { name: '', - type: 'uint32', - internalType: 'uint32', + type: 'uint256', + internalType: 'uint256', }, ], stateMutability: 'view', }, { type: 'function', - name: 'mbsmRegistry', + name: 'maxBlueprintsPerOperator', inputs: [], outputs: [ { name: '', - type: 'address', - internalType: 'address', - }, + type: 'uint32', + internalType: 'uint32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'mbsmRegistry', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, ], stateMutability: 'view', }, @@ -3118,6 +3023,30 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'operatorAckedCurrentSources', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'operatorStatusRegistry', @@ -3171,6 +3100,25 @@ const ABI = [ ], stateMutability: 'view', }, + { + type: 'function', + name: 'pendingBlueprintOwner', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'pendingDisputeBondRefund', @@ -3677,6 +3625,151 @@ const ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'setBlueprintSources', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'sources', + type: 'tuple[]', + internalType: 'struct Types.BlueprintSource[]', + components: [ + { + name: 'kind', + type: 'uint8', + internalType: 'enum Types.BlueprintSourceKind', + }, + { + name: 'container', + type: 'tuple', + internalType: 'struct Types.ImageRegistrySource', + components: [ + { + name: 'registry', + type: 'string', + internalType: 'string', + }, + { + name: 'image', + type: 'string', + internalType: 'string', + }, + { + name: 'tag', + type: 'string', + internalType: 'string', + }, + ], + }, + { + name: 'wasm', + type: 'tuple', + internalType: 'struct Types.WasmSource', + components: [ + { + name: 'runtime', + type: 'uint8', + internalType: 'enum Types.WasmRuntime', + }, + { + name: 'fetcher', + type: 'uint8', + internalType: 'enum Types.BlueprintFetcherKind', + }, + { + name: 'artifactUri', + type: 'string', + internalType: 'string', + }, + { + name: 'entrypoint', + type: 'string', + internalType: 'string', + }, + ], + }, + { + name: 'native', + type: 'tuple', + internalType: 'struct Types.NativeSource', + components: [ + { + name: 'fetcher', + type: 'uint8', + internalType: 'enum Types.BlueprintFetcherKind', + }, + { + name: 'artifactUri', + type: 'string', + internalType: 'string', + }, + { + name: 'entrypoint', + type: 'string', + internalType: 'string', + }, + ], + }, + { + name: 'testing', + type: 'tuple', + internalType: 'struct Types.TestingSource', + components: [ + { + name: 'cargoPackage', + type: 'string', + internalType: 'string', + }, + { + name: 'cargoBin', + type: 'string', + internalType: 'string', + }, + { + name: 'basePath', + type: 'string', + internalType: 'string', + }, + ], + }, + { + name: 'binaries', + type: 'tuple[]', + internalType: 'struct Types.BlueprintBinary[]', + components: [ + { + name: 'arch', + type: 'uint8', + internalType: 'enum Types.BlueprintArchitecture', + }, + { + name: 'os', + type: 'uint8', + internalType: 'enum Types.BlueprintOperatingSystem', + }, + { + name: 'name', + type: 'string', + internalType: 'string', + }, + { + name: 'sha256', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'setDefaultTntMinExposureBps', @@ -3726,6 +3819,19 @@ const ABI = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'setManagerHookGasLimit', + inputs: [ + { + name: 'limit', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'setMaxBlueprintsPerOperator', @@ -4065,6 +4171,11 @@ const ABI = [ type: 'uint8', internalType: 'uint8', }, + { + name: 'inputsHash', + type: 'bytes32', + internalType: 'bytes32', + }, ], }, { @@ -4403,6 +4514,94 @@ const ABI = [ ], anonymous: false, }, + { + type: 'event', + name: 'BlueprintSourcesAcked', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'operator', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'sourcesHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'BlueprintSourcesUpdated', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'sourceCount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'BlueprintTransferCancelled', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'owner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'BlueprintTransferProposed', + inputs: [ + { + name: 'blueprintId', + type: 'uint64', + indexed: true, + internalType: 'uint64', + }, + { + name: 'from', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'pendingOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, { type: 'event', name: 'BlueprintTransferred', diff --git a/libs/tangle-shared-ui/src/abi/tangleBinaryUpgrade.ts b/libs/tangle-shared-ui/src/abi/tangleBinaryUpgrade.ts index d3390398e3..1e80dbfb94 100644 --- a/libs/tangle-shared-ui/src/abi/tangleBinaryUpgrade.ts +++ b/libs/tangle-shared-ui/src/abi/tangleBinaryUpgrade.ts @@ -468,3 +468,111 @@ const ABI = [ ] as const; export default ABI; + +// ──────────────────────────────────────────────────────────────────────────── +// tnt-core 0.19 read variants +// +// The 0.19 "hash+emit shrink" dropped the URI blobs from on-chain STORAGE: +// `Types.BinaryVersion` no longer carries `binaryUri`, and `Types.Attestation` +// no longer carries `reportUri`. The URIs are now event-sourced +// (`BinaryVersionPublished.binaryUri`, `BinaryVersionAttested.reportUri`). +// +// The WRITE surface is unchanged — `publishBinaryVersion` / `attestBinaryVersion` +// still take the URI as a calldata param — so only these read tuples differ. +// Decoding a 0.18 (URI-bearing) tuple against 0.19 returndata mis-aligns every +// field after the dropped slot, so v019 chains MUST decode with these shorter +// tuples. Selection is keyed by the per-chain `TntCoreRevision`. +// ──────────────────────────────────────────────────────────────────────────── + +const BINARY_VERSION_V019_COMPONENTS = [ + { name: 'versionId', type: 'uint64', internalType: 'uint64' }, + { name: 'sha256Hash', type: 'bytes32', internalType: 'bytes32' }, + { name: 'attestationHash', type: 'bytes32', internalType: 'bytes32' }, + { name: 'publishedAt', type: 'uint64', internalType: 'uint64' }, + { name: 'deprecated', type: 'bool', internalType: 'bool' }, +] as const; + +const ATTESTATION_V019_COMPONENTS = [ + { name: 'attester', type: 'address', internalType: 'address' }, + { name: 'reportHash', type: 'bytes32', internalType: 'bytes32' }, + { name: 'kind', type: 'uint8', internalType: 'enum Types.AttestationKind' }, + { name: 'severityFound', type: 'uint8', internalType: 'uint8' }, + { name: 'attestedAt', type: 'uint64', internalType: 'uint64' }, + { name: 'expiresAt', type: 'uint64', internalType: 'uint64' }, + { name: 'revoked', type: 'bool', internalType: 'bool' }, +] as const; + +/** + * 0.19 read-only fragment for binary versions + attestations. Same function + * names and inputs as the 0.18 ABI above; only the returned struct tuples are + * shorter (the URI slots are gone). Use this ABI for the on-chain READ path on + * v019 chains; keep the default `ABI` for the WRITE path and for 0.18 reads. + */ +export const BINARY_UPGRADE_V019_READ_ABI = [ + { + type: 'function', + name: 'getBinaryVersion', + inputs: [ + { name: 'blueprintId', type: 'uint64', internalType: 'uint64' }, + { name: 'versionId', type: 'uint64', internalType: 'uint64' }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct Types.BinaryVersion', + components: BINARY_VERSION_V019_COMPONENTS, + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'effectiveBinaryVersion', + inputs: [{ name: 'serviceId', type: 'uint64', internalType: 'uint64' }], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct Types.BinaryVersion', + components: BINARY_VERSION_V019_COMPONENTS, + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getAttestation', + inputs: [ + { name: 'blueprintId', type: 'uint64', internalType: 'uint64' }, + { name: 'versionId', type: 'uint64', internalType: 'uint64' }, + { name: 'attestationId', type: 'uint64', internalType: 'uint64' }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct Types.Attestation', + components: ATTESTATION_V019_COMPONENTS, + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'listAttestations', + inputs: [ + { name: 'blueprintId', type: 'uint64', internalType: 'uint64' }, + { name: 'versionId', type: 'uint64', internalType: 'uint64' }, + ], + outputs: [ + { + name: '', + type: 'tuple[]', + internalType: 'struct Types.Attestation[]', + components: ATTESTATION_V019_COMPONENTS, + }, + ], + stateMutability: 'view', + }, +] as const; diff --git a/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts b/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts index 8f6a62a516..74f1dd0c61 100644 --- a/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts +++ b/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts @@ -46,7 +46,10 @@ export enum AuditorTier { export interface Attestation { attester: `0x${string}`; reportHash: `0x${string}`; - reportUri: string; + // tnt-core 0.19 dropped `reportUri` from the on-chain `Attestation` struct; + // it is event-sourced from `BinaryVersionAttested`. `null` on v019 chains + // until the indexer wiring lands (follow-up); a string on legacy/v018 chains. + reportUri: string | null; kind: AttestationKind; severityFound: number; attestedAt: bigint; diff --git a/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts b/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts index 025202aa14..7fe9d6effd 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts @@ -6,11 +6,42 @@ import { import TANGLE_ABI from '../../abi/tangle'; /** - * tnt-core 0.18 `Types.Blueprint` — `operatorCount` was removed from the - * struct (derived from the operator set via `blueprintOperatorCount`), so the - * legacy 7-field tuple in the synced ABI throws on 0.18 returndata and this - * 6-field variant throws on legacy returndata. Selection is keyed by the - * per-chain `TntCoreRevision`; do not merge the two entries into one ABI. + * Pre-0.18 `Types.Blueprint` — the 7-field struct that still carries + * `operatorCount`. Kept as a local fragment because the synced `TANGLE_ABI` is + * now the 0.19 shape (operatorCount moved to the `blueprintOperatorCount` view + * in 0.18), so decoding legacy returndata against the synced ABI throws. + */ +const GET_BLUEPRINT_LEGACY_ABI = [ + { + type: 'function', + name: 'getBlueprint', + stateMutability: 'view', + inputs: [{ name: 'blueprintId', type: 'uint64' }], + outputs: [ + { + name: '', + type: 'tuple', + components: [ + { name: 'owner', type: 'address' }, + { name: 'manager', type: 'address' }, + { name: 'createdAt', type: 'uint64' }, + { name: 'operatorCount', type: 'uint64' }, + { name: 'membership', type: 'uint8' }, + { name: 'pricing', type: 'uint8' }, + { name: 'active', type: 'bool' }, + ], + }, + ], + }, +] as const; + +/** + * tnt-core 0.18+ `Types.Blueprint` — `operatorCount` was removed from the + * struct (derived from the operator set via `blueprintOperatorCount`), so this + * 6-field variant throws on legacy returndata and the legacy 7-field variant + * throws on 0.18/0.19 returndata. Selection is keyed by the per-chain + * `TntCoreRevision`; do not merge the two entries into one ABI. The 0.19 struct + * is identical to 0.18 for these fields, so v018 and v019 share this ABI. */ const GET_BLUEPRINT_V018_ABI = [ { @@ -58,18 +89,12 @@ export const readBlueprintCore = async ( const revision: TntCoreRevision = getTntCoreRevisionByChainId(chainId); if (revision === 'legacy') { - const blueprint = (await publicClient.readContract({ + const blueprint = await publicClient.readContract({ address: tangleAddress, - abi: TANGLE_ABI, + abi: GET_BLUEPRINT_LEGACY_ABI, functionName: 'getBlueprint', args: [blueprintId], - })) as { - owner: Address; - manager: Address; - createdAt: bigint; - operatorCount: number; - active: boolean; - }; + }); return { owner: blueprint.owner, manager: blueprint.manager, diff --git a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts index d501574910..80ae1bc2ca 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts @@ -8,10 +8,15 @@ */ import { useQuery, useQueries } from '@tanstack/react-query'; -import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { + getContractsByChainId, + getTntCoreRevisionByChainId, +} from '@tangle-network/dapp-config/contracts'; import type { Address, PublicClient } from 'viem'; import { useChainId, usePublicClient } from 'wagmi'; -import BinaryUpgradeABI from '../../abi/tangleBinaryUpgrade'; +import BinaryUpgradeABI, { + BINARY_UPGRADE_V019_READ_ABI, +} from '../../abi/tangleBinaryUpgrade'; import BlueprintAuditorsABI from '../../abi/blueprintAuditors'; import { type AttestationKind, @@ -28,7 +33,11 @@ import { export interface BinaryVersion { versionId: bigint; sha256Hash: `0x${string}`; - binaryUri: string; + // tnt-core 0.19 dropped `binaryUri` from the on-chain `BinaryVersion` struct; + // it is event-sourced from `BinaryVersionPublished`. `null` on v019 chains + // until the indexer wiring lands (follow-up); a non-empty string on + // legacy/v018 chains that still store it in the returned tuple. + binaryUri: string | null; attestationHash: `0x${string}`; publishedAt: bigint; deprecated: boolean; @@ -54,6 +63,20 @@ export interface ServiceUpgradeState { const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +/** + * Selects the read-path ABI for binary-version / attestation struct getters. + * + * On v019 the returned tuples dropped their URI slots, so decoding them against + * the URI-bearing 0.18 tuple mis-aligns every subsequent field. Legacy/v018 + * chains keep the full ABI. The WRITE path always uses `BinaryUpgradeABI` (the + * URI is still a calldata param on every revision) — this helper only governs + * the four struct-returning views. + */ +const binaryReadAbiFor = (chainId: number) => + getTntCoreRevisionByChainId(chainId) === 'v019' + ? BINARY_UPGRADE_V019_READ_ABI + : BinaryUpgradeABI; + const tangleAddressFor = (chainId: number): Address | null => { try { const contracts = getContractsByChainId(chainId); @@ -74,26 +97,30 @@ const auditorRegistryAddressFor = (chainId: number): Address | null => { } }; +// `binaryUri` is absent from the 0.19 struct returndata. Accept it as optional +// so a single normalizer covers both tuple shapes; missing → `null`. const normalizeBinaryVersion = (raw: { versionId: bigint; sha256Hash: `0x${string}`; - binaryUri: string; + binaryUri?: string; attestationHash: `0x${string}`; publishedAt: bigint; deprecated: boolean; }): BinaryVersion => ({ versionId: BigInt(raw.versionId), sha256Hash: raw.sha256Hash, - binaryUri: raw.binaryUri, + binaryUri: raw.binaryUri ?? null, attestationHash: raw.attestationHash, publishedAt: BigInt(raw.publishedAt), deprecated: raw.deprecated, }); +// `reportUri` is absent from the 0.19 attestation struct returndata; missing → +// `null` (event-sourced from `BinaryVersionAttested.reportUri` in a follow-up). const normalizeAttestation = (raw: { attester: Address; reportHash: `0x${string}`; - reportUri: string; + reportUri?: string; kind: number; severityFound: number; attestedAt: bigint; @@ -102,7 +129,7 @@ const normalizeAttestation = (raw: { }): Attestation => ({ attester: raw.attester, reportHash: raw.reportHash, - reportUri: raw.reportUri, + reportUri: raw.reportUri ?? null, kind: raw.kind as AttestationKind, severityFound: raw.severityFound, attestedAt: BigInt(raw.attestedAt), @@ -134,13 +161,14 @@ export const fetchBinaryVersions = async ( // The version count is bounded by publish events; batching all reads in // parallel via Promise.all is the same pattern fetchBlueprintsOnChain // uses for the blueprint list itself. + const readAbi = binaryReadAbiFor(chainId); const ids = Array.from({ length: Number(count) }, (_, i) => BigInt(i)); const versions = await Promise.all( ids.map(async (versionId) => { try { const raw = (await publicClient.readContract({ address: tangle, - abi: BinaryUpgradeABI, + abi: readAbi, functionName: 'getBinaryVersion', args: [blueprintId, versionId], })) as Parameters[0]; @@ -166,7 +194,7 @@ export const fetchAttestations = async ( try { const raw = (await publicClient.readContract({ address: tangle, - abi: BinaryUpgradeABI, + abi: binaryReadAbiFor(chainId), functionName: 'listAttestations', args: [blueprintId, versionId], })) as Array[0]>; @@ -270,7 +298,7 @@ export const useEffectiveBinaryVersion = ( try { const raw = (await publicClient.readContract({ address: tangle, - abi: BinaryUpgradeABI, + abi: binaryReadAbiFor(chainId), functionName: 'effectiveBinaryVersion', args: [serviceId], })) as Parameters[0]; @@ -332,7 +360,7 @@ export const useServiceUpgradeState = ( try { const raw = (await publicClient.readContract({ address: tangle, - abi: BinaryUpgradeABI, + abi: binaryReadAbiFor(chainId), functionName: 'effectiveBinaryVersion', args: [serviceId], })) as Parameters[0]; diff --git a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts index 68338cbaf2..44be325c7a 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts @@ -8,7 +8,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAccount, useChainId, usePublicClient } from 'wagmi'; -import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { + getContractsByChainId, + getTntCoreRevisionByChainId, + type TntCoreRevision, +} from '@tangle-network/dapp-config/contracts'; import { Address, type Hash, isAddress, zeroAddress } from 'viem'; import TANGLE_ABI from '../../abi/tangle'; import { @@ -451,12 +455,19 @@ export const buildSlashTimeline = ( nowUnixSeconds, ); + // `proposedAt` is 0 when it is unknown — on v019 the on-chain read no longer + // returns it (event-sourced; backfilled from the `SlashProposed` block in a + // follow-up). Surface `null` rather than a bogus 1970 timestamp so the UI can + // omit the date instead of rendering the epoch. + const hasProposedAt = slash.proposedAt > BigInt(0); const proposed: SlashTimelineStage = { key: 'proposed', label: 'Proposed', state: 'done', - timestamp: slash.proposedAt, - description: 'Slash proposal created on-chain.', + timestamp: hasProposedAt ? slash.proposedAt : null, + description: hasProposedAt + ? 'Slash proposal created on-chain.' + : 'Slash proposal created on-chain (exact time not recorded on this chain).', }; const disputeWindow: SlashTimelineStage = { @@ -750,14 +761,78 @@ const fetchProposableServices = async ( }); }; +/** + * Full (pre-0.19) `getSlashProposal` return tuple. tnt-core 0.19 slimmed the + * on-chain `SlashProposal` struct — it dropped `proposedAt`, `disputeReason`, + * and `disputedAt` (all now reconstructable off-chain from the `SlashProposed` + * / `SlashDisputed` event blocks). The synced `TANGLE_ABI` carries the SHORT + * 0.19 tuple, so legacy/v018 chains must decode `getSlashProposal` with this + * full-tuple ABI or every field after `evidence` mis-aligns. + */ +const GET_SLASH_PROPOSAL_V018_ABI = [ + { + type: 'function', + name: 'getSlashProposal', + stateMutability: 'view', + inputs: [{ name: 'slashId', type: 'uint64', internalType: 'uint64' }], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct SlashingLib.SlashProposal', + components: [ + { name: 'serviceId', type: 'uint64', internalType: 'uint64' }, + { name: 'operator', type: 'address', internalType: 'address' }, + { name: 'proposer', type: 'address', internalType: 'address' }, + { name: 'slashBps', type: 'uint16', internalType: 'uint16' }, + { name: 'effectiveSlashBps', type: 'uint16', internalType: 'uint16' }, + { name: 'evidence', type: 'bytes32', internalType: 'bytes32' }, + { name: 'proposedAt', type: 'uint64', internalType: 'uint64' }, + { name: 'executeAfter', type: 'uint64', internalType: 'uint64' }, + { + name: 'status', + type: 'uint8', + internalType: 'enum SlashingLib.SlashStatus', + }, + { name: 'disputeReason', type: 'string', internalType: 'string' }, + { name: 'disputer', type: 'address', internalType: 'address' }, + { name: 'disputeBond', type: 'uint256', internalType: 'uint256' }, + { name: 'disputedAt', type: 'uint64', internalType: 'uint64' }, + { name: 'disputeDeadline', type: 'uint64', internalType: 'uint64' }, + ], + }, + ], + }, +] as const; + +/** + * Selects the ABI for the `getSlashProposal` READ by revision. v019 uses the + * synced (short-tuple) `TANGLE_ABI`; legacy/v018 use the full-tuple fragment. + */ +const slashProposalReadAbiFor = (chainId: number) => + getTntCoreRevisionByChainId(chainId) === 'v019' + ? TANGLE_ABI + : GET_SLASH_PROPOSAL_V018_ABI; + const normalizeOnChainSlashProposal = ( slashId: bigint, proposal: any, + revision: TntCoreRevision, ): SlashProposal => { - // Tuple layout from getSlashProposal (tnt-core v0.13.0): + // Full tuple layout (legacy / v018): // 0 serviceId, 1 operator, 2 proposer, 3 slashBps, 4 effectiveSlashBps, // 5 evidence, 6 proposedAt, 7 executeAfter, 8 status, 9 disputeReason, // 10 disputer, 11 disputeBond, 12 disputedAt, 13 disputeDeadline. + // + // v019 slimmed tuple (proposedAt / disputeReason / disputedAt dropped): + // 0 serviceId, 1 operator, 2 proposer, 3 slashBps, 4 effectiveSlashBps, + // 5 evidence, 6 executeAfter, 7 status, 8 disputer, 9 disputeBond, + // 10 disputeDeadline. On v019 we read by NAME (viem returns a named tuple), + // and the dropped fields are left at their event-sourced defaults: + // proposedAt = 0, disputeReason = null, disputedAt = 0. A follow-up backfills + // proposedAt / disputedAt from the `SlashProposed` / `SlashDisputed` event + // blocks and disputeReason from the indexer. + const isV019 = revision === 'v019'; const serviceId = proposal?.serviceId !== undefined ? BigInt(proposal.serviceId.toString()) @@ -777,27 +852,46 @@ const normalizeOnChainSlashProposal = ( const evidence = (proposal?.evidence ?? proposal?.[5] ?? '0x') as `0x${string}`; - const proposedAt = BigInt( - proposal?.proposedAt?.toString() ?? proposal?.[6]?.toString() ?? 0, - ); + // Positional fallbacks below diverge by revision: v019 dropped three fields, + // shifting every index after `evidence` down by one (executeAfter 7→6, etc.) + // and removing proposedAt(6)/disputeReason(9)/disputedAt(12) entirely. viem + // returns a named tuple so name access wins; the indices only backstop a + // positional decode. Guard the dropped fields on v019 so a positional read + // never grabs a neighbouring slot. + const proposedAt = isV019 + ? BigInt(proposal?.proposedAt?.toString() ?? 0) + : BigInt( + proposal?.proposedAt?.toString() ?? proposal?.[6]?.toString() ?? 0, + ); const executeAfter = BigInt( - proposal?.executeAfter?.toString() ?? proposal?.[7]?.toString() ?? 0, + proposal?.executeAfter?.toString() ?? + (isV019 ? proposal?.[6]?.toString() : proposal?.[7]?.toString()) ?? + 0, ); - const statusValue = proposal?.status ?? proposal?.[8] ?? 0; - const disputeReason = (proposal?.disputeReason ?? proposal?.[9] ?? null) as - | string - | null; + const statusValue = + proposal?.status ?? (isV019 ? proposal?.[7] : proposal?.[8]) ?? 0; + const disputeReason = ( + isV019 + ? (proposal?.disputeReason ?? null) + : (proposal?.disputeReason ?? proposal?.[9] ?? null) + ) as string | null; const disputer = (proposal?.disputer ?? - proposal?.[10] ?? + (isV019 ? proposal?.[8] : proposal?.[10]) ?? zeroAddress) as Address; const disputeBond = BigInt( - proposal?.disputeBond?.toString() ?? proposal?.[11]?.toString() ?? 0, - ); - const disputedAt = BigInt( - proposal?.disputedAt?.toString() ?? proposal?.[12]?.toString() ?? 0, + proposal?.disputeBond?.toString() ?? + (isV019 ? proposal?.[9]?.toString() : proposal?.[11]?.toString()) ?? + 0, ); + const disputedAt = isV019 + ? BigInt(proposal?.disputedAt?.toString() ?? 0) + : BigInt( + proposal?.disputedAt?.toString() ?? proposal?.[12]?.toString() ?? 0, + ); const disputeDeadline = BigInt( - proposal?.disputeDeadline?.toString() ?? proposal?.[13]?.toString() ?? 0, + proposal?.disputeDeadline?.toString() ?? + (isV019 ? proposal?.[10]?.toString() : proposal?.[13]?.toString()) ?? + 0, ); return { @@ -955,12 +1049,16 @@ export const useSlashProposalDetails = ( const proposal = await publicClient.readContract({ address: contracts.tangle, - abi: TANGLE_ABI, + abi: slashProposalReadAbiFor(chainId), functionName: 'getSlashProposal', args: [slashId], }); - return normalizeOnChainSlashProposal(slashId, proposal); + return normalizeOnChainSlashProposal( + slashId, + proposal, + getTntCoreRevisionByChainId(chainId), + ); }, enabled: enabled && slashId !== undefined && !!publicClient, staleTime: 15_000, @@ -1209,7 +1307,7 @@ export const useProposeSlashTx = () => { const contracts = getContractsByChainId(chainId); const proposal = await publicClient.readContract({ address: contracts.tangle, - abi: TANGLE_ABI, + abi: slashProposalReadAbiFor(chainId), functionName: 'getSlashProposal', args: [slashId], }); @@ -1217,7 +1315,11 @@ export const useProposeSlashTx = () => { return { hash: result.hash, slashId, - proposal: normalizeOnChainSlashProposal(slashId, proposal), + proposal: normalizeOnChainSlashProposal( + slashId, + proposal, + getTntCoreRevisionByChainId(chainId), + ), }; } catch { return { diff --git a/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts b/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts index 578db0f4e1..c82d7dcdcf 100644 --- a/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts +++ b/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts @@ -5,8 +5,32 @@ import { useQuery } from '@tanstack/react-query'; import { Address, zeroAddress } from 'viem'; import { useChainId, usePublicClient } from 'wagmi'; -import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; -import TangleABI from '../../abi/tangle'; +import { + getContractsByChainId, + getTntCoreRevisionByChainId, +} from '@tangle-network/dapp-config/contracts'; + +/** + * tnt-core 0.19 removed the `canScheduleExit(uint64,address)` view, so it is no + * longer present in the synced `TANGLE_ABI`. Keep a local fragment for the + * legacy/v018 direct-read path; v019 chains degrade to a safe default below and + * never reach this ABI. + */ +const CAN_SCHEDULE_EXIT_ABI = [ + { + type: 'function', + name: 'canScheduleExit', + stateMutability: 'view', + inputs: [ + { name: 'serviceId', type: 'uint64', internalType: 'uint64' }, + { name: 'operator', type: 'address', internalType: 'address' }, + ], + outputs: [ + { name: 'canExit', type: 'bool', internalType: 'bool' }, + { name: 'reason', type: 'string', internalType: 'string' }, + ], + }, +] as const; export interface CanScheduleExitResult { canExit: boolean; @@ -52,9 +76,22 @@ export const useCanScheduleExit = ( return { canExit: false, reason: 'Contract not available' }; } + // tnt-core 0.19 removed the `canScheduleExit(uint64,address)` view. There + // is no drop-in on-chain read on v019 (the eligibility is derivable from + // `getExitStatus` + the service's exit config, wired in a follow-up), so + // degrade to a safe, non-blocking default: the chain still enforces exit + // rules at `scheduleExit` time. Fail closed on `canExit` so we never + // render an exit action as available when we cannot verify it. + if (getTntCoreRevisionByChainId(chainId) === 'v019') { + return { + canExit: false, + reason: 'Exit eligibility unavailable on this chain', + }; + } + const result = await publicClient.readContract({ address: tangleAddress, - abi: TangleABI, + abi: CAN_SCHEDULE_EXIT_ABI, functionName: 'canScheduleExit', args: [serviceId, operator], }); From 115a2b6132c8e17cb63c303f672f8f1227c2c91f Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 7 Jul 2026 19:58:25 -0600 Subject: [PATCH 2/2] fix(0.19): expiryTimestamp lock-basis + operatorCount width + revision-read tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the blocking review findings on the 0.19 adoption: - CRITICAL: withdrawal lock displays were reading LockInfo.expiryBlock, which the synced 0.19 ABI renamed to expiryTimestamp — viem keys decoded tuples by ABI component name, so every read was undefined and all locks showed as unlocked. Read expiryTimestamp on every revision; branch the comparison basis: legacy stores a BLOCK NUMBER (vs currentBlockNumber), 0.18/0.19 store a unix TIMESTAMP (vs wall-clock seconds). Same uint64 slot, only the value semantics differ. - HIGH: legacy getBlueprint ABI had operatorCount as uint64; the real pre-#194 struct is uint32. Wrong width mis-aligned the following membership/pricing/active fields on legacy. - MED: blueprintSources -> blueprintSourcesHash — grep confirms zero dapp callers of either; documented the rename (the setBlueprintSources WRITE is unchanged). - MED: canScheduleExit view removed -> fail-closed on v019; added TODO(v019) markers at the fallback and the exit-button block to track the getExitStatus follow-up. - MED: extracted the pure decode/ABI-select logic out of the wagmi-importing hooks into wagmi-free modules (slashProposal.ts, binaryVersion.ts, re-exported, zero consumer change) and added 11 revision tests: v019 nulls binaryUri/reportUri without throwing; v018 decodes the full 14-field slash tuple; v019 short-tuple positional decode has no index slip. - MED: documented the v019 LockInfo/expiryTimestamp semantics in getTntCoreRevisionByChainId. Verified: nx typecheck (tangle-dapp + tangle-cloud) pass; nx test tangle-shared-ui 85 pass (11 new + 74 existing); prettier clean; no .expiryBlock runtime reads remain. --- .../pages/services/[id]/OperatorExitPanel.tsx | 7 + .../src/pages/staking/withdraw/index.tsx | 26 +- libs/dapp-config/src/contracts.ts | 22 +- .../src/data/blueprints/binaryVersion.ts | 90 +++++++ .../src/data/blueprints/readBlueprintCore.ts | 6 +- .../useBinaryVersions.revision.spec.ts | 134 ++++++++++ .../src/data/blueprints/useBinaryVersions.ts | 99 ++------ .../src/data/graphql/slashProposal.ts | 217 ++++++++++++++++ .../data/graphql/useSlashing.revision.spec.ts | 155 ++++++++++++ .../src/data/graphql/useSlashing.ts | 239 ++---------------- .../src/data/services/useCanScheduleExit.ts | 3 + 11 files changed, 684 insertions(+), 314 deletions(-) create mode 100644 libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts create mode 100644 libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.revision.spec.ts create mode 100644 libs/tangle-shared-ui/src/data/graphql/slashProposal.ts create mode 100644 libs/tangle-shared-ui/src/data/graphql/useSlashing.revision.spec.ts diff --git a/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx index 1f84b4c614..e51cfaa6d6 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx @@ -313,6 +313,13 @@ const OperatorExitPanel: FC = ({ {/* Current Exit Status */} + {/* + TODO(v019): `useCanScheduleExit` fail-closes to `{ canExit: false }` on + v019 chains (tnt-core 0.19 removed the `canScheduleExit` view), so the + Schedule-Exit button below is always gated off there and only the + yellow "unavailable" reason renders. Wire getExitStatus-based + eligibility in the hook to re-enable scheduling on v019. + */} {exitStatus === ExitStatus.None && (
{canScheduleExit?.canExit ? ( diff --git a/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx b/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx index de7e630f36..3708440ebe 100644 --- a/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx +++ b/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx @@ -296,19 +296,27 @@ const StakingWithdrawForm: FC = () => { const locks = res.result as Array<{ amount: bigint; multiplier: number; - expiryBlock: bigint; + // The synced (0.18+) `MULTI_ASSET_DELEGATION_ABI` names field 2 + // `expiryTimestamp`; viem keys decoded tuples by ABI component name, + // so this is the property to read on every revision. The *value* is a + // block number on `legacy` and a unix timestamp on `v018`/`v019` — the + // same positional slot (uint64), so the decode succeeds either way and + // only the comparison basis must branch below. + expiryTimestamp: bigint; }>; - // tnt-core 0.18 redefined the LockInfo slot as a unix TIMESTAMP while - // keeping the same position/type, so the decode silently succeeds on - // both revisions — the comparison basis is what must branch. Comparing - // a timestamp against a block number reads "locked for decades". + // Pre-0.18 (`legacy`) stored `expiryBlock` as a BLOCK NUMBER, so it must + // be compared against the current block height. tnt-core 0.18 redefined + // the slot as a unix TIMESTAMP (seconds) and 0.19 kept that semantics, so + // `v018`/`v019` compare against wall-clock seconds. Comparing a timestamp + // against a block number (or vice versa) reads "locked for decades". + const isLegacyBlockBasis = + getTntCoreRevisionByChainId(chainId) === 'legacy'; const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); const locked = locks.reduce((sum, lock) => { - const stillLocked = - getTntCoreRevisionByChainId(chainId) === 'v018' - ? lock.expiryBlock > nowSeconds - : lock.expiryBlock > currentBlockNumber; + const stillLocked = isLegacyBlockBasis + ? lock.expiryTimestamp > currentBlockNumber + : lock.expiryTimestamp > nowSeconds; return stillLocked ? sum + lock.amount : sum; }, BigInt(0)); diff --git a/libs/dapp-config/src/contracts.ts b/libs/dapp-config/src/contracts.ts index 7c8b0d0d81..853751041d 100644 --- a/libs/dapp-config/src/contracts.ts +++ b/libs/dapp-config/src/contracts.ts @@ -143,18 +143,26 @@ export const ETHEREUM_HOLESKY_CONTRACTS: ContractAddresses = { * - `v018`: tnt-core 0.18 greenfield redeploys — `operatorCount` moved to the * `blueprintOperatorCount(uint64)` view (struct is 6 fields; decoding the * legacy 7-field tuple throws), display prose is event-sourced from - * `BlueprintDefinitionRecorded`, and the `LockInfo` slot named - * `expiryBlock` carries a unix TIMESTAMP. + * `BlueprintDefinitionRecorded`, and the third `LockInfo` slot — renamed + * `expiryBlock` -> `expiryTimestamp` in the synced ABI, same uint64 position + * — carries a unix TIMESTAMP (seconds) rather than a block number. * - `v019`: tnt-core 0.19 hash+emit shrink — several on-chain reads lost * storage fields that are now event-sourced. The `getSlashProposal` tuple * dropped `proposedAt` / `disputeReason` / `disputedAt`; the binary-version * read (`getBinaryVersion` / `effectiveBinaryVersion`) dropped `binaryUri`; * the attestation read (`getAttestation` / `listAttestations`) dropped - * `reportUri`; and the `canScheduleExit(uint64,address)` view was removed - * (derive from `getExitStatus`). Publish/attest WRITE params are unchanged — - * only the stored/returned fields moved off-chain. Decoding a v0.18 tuple - * against v0.19 returndata throws, so these reads must select the shorter - * ABI by revision; do not merge the two entries into one ABI. + * `reportUri`; `blueprintSources(uint64)->BlueprintSource[]` was replaced by + * `blueprintSourcesHash(uint64)->bytes32` (the source list is event-sourced; + * the `setBlueprintSources` WRITE still takes the full `BlueprintSource[]`); + * and the `canScheduleExit(uint64,address)` view was removed (derive from + * `getExitStatus`). `LockInfo.expiryTimestamp` keeps the v018 semantics — + * a uint64 unix TIMESTAMP (seconds), NOT a block number — so the withdraw + * lock/countdown math compares it against wall-clock seconds, not block + * height, exactly as on v018 (confirmed against tnt-core + * `Types.LockInfo.expiryTimestamp`). Publish/attest WRITE params are + * unchanged — only the stored/returned fields moved off-chain. Decoding a + * v0.18 tuple against v0.19 returndata throws, so these reads must select the + * shorter ABI by revision; do not merge the two entries into one ABI. * * Flip a chain to `v018` / `v019` in the same change that updates its * addresses to the redeployed contracts — never separately. diff --git a/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts b/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts new file mode 100644 index 0000000000..68e5864d4e --- /dev/null +++ b/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts @@ -0,0 +1,90 @@ +/** + * Pure (wagmi-free) binary-version / attestation decode + ABI selection. + * + * Split out of `useBinaryVersions.ts` so it can be unit-tested without pulling + * wagmi/react-query (ESM) into the jest module graph. The hook module + * re-exports these, so consumers see no change. + * + * tnt-core 0.19 dropped the URI blobs from the on-chain STORAGE structs: + * `Types.BinaryVersion` lost `binaryUri` and `Types.Attestation` lost + * `reportUri` (both now event-sourced). So the v019 read tuples are shorter and + * must be decoded with the shorter ABI; the normalizers accept the URI slot as + * optional and default it to `null`. + */ + +import type { Address } from 'viem'; +import { getTntCoreRevisionByChainId } from '@tangle-network/dapp-config/contracts'; +import BinaryUpgradeABI, { + BINARY_UPGRADE_V019_READ_ABI, +} from '../../abi/tangleBinaryUpgrade'; +import type { + AttestationKind, + Attestation, +} from '../../blueprintApps/trustScore'; + +export interface BinaryVersion { + versionId: bigint; + sha256Hash: `0x${string}`; + // tnt-core 0.19 dropped `binaryUri` from the on-chain `BinaryVersion` struct; + // it is event-sourced from `BinaryVersionPublished`. `null` on v019 chains + // until the indexer wiring lands (follow-up); a non-empty string on + // legacy/v018 chains that still store it in the returned tuple. + binaryUri: string | null; + attestationHash: `0x${string}`; + publishedAt: bigint; + deprecated: boolean; +} + +/** + * Selects the read-path ABI for binary-version / attestation struct getters. + * + * On v019 the returned tuples dropped their URI slots, so decoding them against + * the URI-bearing 0.18 tuple mis-aligns every subsequent field. Legacy/v018 + * chains keep the full ABI. The WRITE path always uses `BinaryUpgradeABI` (the + * URI is still a calldata param on every revision) — this helper only governs + * the four struct-returning views. + */ +export const binaryReadAbiFor = (chainId: number) => + getTntCoreRevisionByChainId(chainId) === 'v019' + ? BINARY_UPGRADE_V019_READ_ABI + : BinaryUpgradeABI; + +// `binaryUri` is absent from the 0.19 struct returndata. Accept it as optional +// so a single normalizer covers both tuple shapes; missing → `null`. +export const normalizeBinaryVersion = (raw: { + versionId: bigint; + sha256Hash: `0x${string}`; + binaryUri?: string; + attestationHash: `0x${string}`; + publishedAt: bigint; + deprecated: boolean; +}): BinaryVersion => ({ + versionId: BigInt(raw.versionId), + sha256Hash: raw.sha256Hash, + binaryUri: raw.binaryUri ?? null, + attestationHash: raw.attestationHash, + publishedAt: BigInt(raw.publishedAt), + deprecated: raw.deprecated, +}); + +// `reportUri` is absent from the 0.19 attestation struct returndata; missing → +// `null` (event-sourced from `BinaryVersionAttested.reportUri` in a follow-up). +export const normalizeAttestation = (raw: { + attester: Address; + reportHash: `0x${string}`; + reportUri?: string; + kind: number; + severityFound: number; + attestedAt: bigint; + expiresAt: bigint; + revoked: boolean; +}): Attestation => ({ + attester: raw.attester, + reportHash: raw.reportHash, + reportUri: raw.reportUri ?? null, + kind: raw.kind as AttestationKind, + severityFound: raw.severityFound, + attestedAt: BigInt(raw.attestedAt), + expiresAt: BigInt(raw.expiresAt), + revoked: raw.revoked, +}); diff --git a/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts b/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts index 7fe9d6effd..721b32d60d 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts @@ -25,7 +25,11 @@ const GET_BLUEPRINT_LEGACY_ABI = [ { name: 'owner', type: 'address' }, { name: 'manager', type: 'address' }, { name: 'createdAt', type: 'uint64' }, - { name: 'operatorCount', type: 'uint64' }, + // Legacy `Types.Blueprint.operatorCount` is `uint32`, not `uint64` + // (confirmed against the pre-#194 Solidity struct). A wrong width + // here mis-aligns the packed tail (membership/pricing/active), so it + // must match the on-chain type exactly. + { name: 'operatorCount', type: 'uint32' }, { name: 'membership', type: 'uint8' }, { name: 'pricing', type: 'uint8' }, { name: 'active', type: 'bool' }, diff --git a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.revision.spec.ts b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.revision.spec.ts new file mode 100644 index 0000000000..32eae979e3 --- /dev/null +++ b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.revision.spec.ts @@ -0,0 +1,134 @@ +/** + * Revision-aware binary-version / attestation decode coverage. + * + * tnt-core 0.19 dropped the URI blobs from the on-chain STORAGE structs: + * `Types.BinaryVersion` lost `binaryUri` and `Types.Attestation` lost + * `reportUri` (both now event-sourced). `useBinaryVersions` picks the read ABI + * by revision and the normalizers accept the URI slot as optional. These tests + * pin that: v019 tuples decode with a `null` URI and never throw, while the + * legacy/v018 tuples still surface the full URI. + */ + +import { + binaryReadAbiFor, + normalizeAttestation, + normalizeBinaryVersion, +} from './binaryVersion'; + +const revisionByChainId: Record = { + 1: 'legacy', + 18: 'v018', + 19: 'v019', +}; + +jest.mock('@tangle-network/dapp-config/contracts', () => ({ + __esModule: true, + getTntCoreRevisionByChainId: (chainId: number) => + revisionByChainId[chainId] ?? 'legacy', + getContractsByChainId: () => ({ + tangle: '0x0000000000000000000000000000000000000001', + blueprintAuditors: '0x0000000000000000000000000000000000000002', + }), +})); + +const SHA = + '0x1111111111111111111111111111111111111111111111111111111111111111'; +const ATT_HASH = + '0x2222222222222222222222222222222222222222222222222222222222222222'; +const REPORT_HASH = + '0x3333333333333333333333333333333333333333333333333333333333333333'; +const ATTESTER = '0x4444444444444444444444444444444444444444'; + +describe('binaryReadAbiFor', () => { + it('selects the URI-bearing default ABI for legacy and v018', () => { + const legacy = binaryReadAbiFor(1); + const v018 = binaryReadAbiFor(18); + expect(v018).toBe(legacy); + // The default ABI is a large multi-function array. + expect(legacy.length).toBeGreaterThan(4); + }); + + it('selects the shorter no-URI read ABI for v019', () => { + const v019 = binaryReadAbiFor(19); + const legacy = binaryReadAbiFor(1); + expect(v019).not.toBe(legacy); + // The v019 read fragment is exactly the four struct-returning views. + expect(v019).toHaveLength(4); + const getBinaryVersion = (v019 as ReadonlyArray<{ name: string }>).find( + (fn) => fn.name === 'getBinaryVersion', + ) as { + outputs: [{ components: { name: string }[] }]; + }; + const componentNames = getBinaryVersion.outputs[0].components.map( + (c) => c.name, + ); + expect(componentNames).not.toContain('binaryUri'); + }); +}); + +describe('normalizeBinaryVersion', () => { + it('nulls binaryUri on the v019 tuple without throwing', () => { + const result = normalizeBinaryVersion({ + versionId: 3n, + sha256Hash: SHA, + // binaryUri absent — the 0.19 struct dropped it. + attestationHash: ATT_HASH, + publishedAt: 1_700n, + deprecated: false, + }); + expect(result.versionId).toBe(3n); + expect(result.sha256Hash).toBe(SHA); + expect(result.binaryUri).toBeNull(); + expect(result.attestationHash).toBe(ATT_HASH); + expect(result.publishedAt).toBe(1_700n); + expect(result.deprecated).toBe(false); + }); + + it('preserves binaryUri on the full legacy/v018 tuple', () => { + const result = normalizeBinaryVersion({ + versionId: 3n, + sha256Hash: SHA, + binaryUri: 'ipfs://binary', + attestationHash: ATT_HASH, + publishedAt: 1_700n, + deprecated: true, + }); + expect(result.binaryUri).toBe('ipfs://binary'); + expect(result.deprecated).toBe(true); + }); +}); + +describe('normalizeAttestation', () => { + it('nulls reportUri on the v019 tuple without throwing', () => { + const result = normalizeAttestation({ + attester: ATTESTER, + reportHash: REPORT_HASH, + // reportUri absent — the 0.19 struct dropped it. + kind: 0, + severityFound: 1, + attestedAt: 1_800n, + expiresAt: 0n, + revoked: false, + }); + expect(result.attester).toBe(ATTESTER); + expect(result.reportHash).toBe(REPORT_HASH); + expect(result.reportUri).toBeNull(); + expect(result.severityFound).toBe(1); + expect(result.attestedAt).toBe(1_800n); + expect(result.revoked).toBe(false); + }); + + it('preserves reportUri on the full legacy/v018 tuple', () => { + const result = normalizeAttestation({ + attester: ATTESTER, + reportHash: REPORT_HASH, + reportUri: 'ipfs://report', + kind: 0, + severityFound: 0, + attestedAt: 1_800n, + expiresAt: 0n, + revoked: false, + }); + expect(result.reportUri).toBe('ipfs://report'); + }); +}); diff --git a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts index 80ae1bc2ca..e34c69a235 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts @@ -8,40 +8,35 @@ */ import { useQuery, useQueries } from '@tanstack/react-query'; -import { - getContractsByChainId, - getTntCoreRevisionByChainId, -} from '@tangle-network/dapp-config/contracts'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; import type { Address, PublicClient } from 'viem'; import { useChainId, usePublicClient } from 'wagmi'; -import BinaryUpgradeABI, { - BINARY_UPGRADE_V019_READ_ABI, -} from '../../abi/tangleBinaryUpgrade'; +import BinaryUpgradeABI from '../../abi/tangleBinaryUpgrade'; import BlueprintAuditorsABI from '../../abi/blueprintAuditors'; import { - type AttestationKind, type Attestation, type Auditor, type AttestationWithAuditor, type AuditorTier, } from '../../blueprintApps/trustScore'; - -// ───────────────────────────────────────────────────────────────────────── -// Types — mirror the on-chain ABI exactly so callers don't have to translate -// ───────────────────────────────────────────────────────────────────────── - -export interface BinaryVersion { - versionId: bigint; - sha256Hash: `0x${string}`; - // tnt-core 0.19 dropped `binaryUri` from the on-chain `BinaryVersion` struct; - // it is event-sourced from `BinaryVersionPublished`. `null` on v019 chains - // until the indexer wiring lands (follow-up); a non-empty string on - // legacy/v018 chains that still store it in the returned tuple. - binaryUri: string | null; - attestationHash: `0x${string}`; - publishedAt: bigint; - deprecated: boolean; -} +// `BinaryVersion` + the revision-aware ABI selection and tuple normalizers live +// in the pure (wagmi-free, unit-tested) `binaryVersion` module. +import { + type BinaryVersion, + binaryReadAbiFor, + normalizeAttestation, + normalizeBinaryVersion, +} from './binaryVersion'; + +// Re-exported from the pure `binaryVersion` module so existing importers keep +// resolving `BinaryVersion` / the normalizers / `binaryReadAbiFor` from +// `useBinaryVersions`. +export { + binaryReadAbiFor, + normalizeAttestation, + normalizeBinaryVersion, +} from './binaryVersion'; +export type { BinaryVersion } from './binaryVersion'; export enum UpgradePolicy { APPROVE = 0, @@ -63,20 +58,6 @@ export interface ServiceUpgradeState { const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -/** - * Selects the read-path ABI for binary-version / attestation struct getters. - * - * On v019 the returned tuples dropped their URI slots, so decoding them against - * the URI-bearing 0.18 tuple mis-aligns every subsequent field. Legacy/v018 - * chains keep the full ABI. The WRITE path always uses `BinaryUpgradeABI` (the - * URI is still a calldata param on every revision) — this helper only governs - * the four struct-returning views. - */ -const binaryReadAbiFor = (chainId: number) => - getTntCoreRevisionByChainId(chainId) === 'v019' - ? BINARY_UPGRADE_V019_READ_ABI - : BinaryUpgradeABI; - const tangleAddressFor = (chainId: number): Address | null => { try { const contracts = getContractsByChainId(chainId); @@ -97,46 +78,6 @@ const auditorRegistryAddressFor = (chainId: number): Address | null => { } }; -// `binaryUri` is absent from the 0.19 struct returndata. Accept it as optional -// so a single normalizer covers both tuple shapes; missing → `null`. -const normalizeBinaryVersion = (raw: { - versionId: bigint; - sha256Hash: `0x${string}`; - binaryUri?: string; - attestationHash: `0x${string}`; - publishedAt: bigint; - deprecated: boolean; -}): BinaryVersion => ({ - versionId: BigInt(raw.versionId), - sha256Hash: raw.sha256Hash, - binaryUri: raw.binaryUri ?? null, - attestationHash: raw.attestationHash, - publishedAt: BigInt(raw.publishedAt), - deprecated: raw.deprecated, -}); - -// `reportUri` is absent from the 0.19 attestation struct returndata; missing → -// `null` (event-sourced from `BinaryVersionAttested.reportUri` in a follow-up). -const normalizeAttestation = (raw: { - attester: Address; - reportHash: `0x${string}`; - reportUri?: string; - kind: number; - severityFound: number; - attestedAt: bigint; - expiresAt: bigint; - revoked: boolean; -}): Attestation => ({ - attester: raw.attester, - reportHash: raw.reportHash, - reportUri: raw.reportUri ?? null, - kind: raw.kind as AttestationKind, - severityFound: raw.severityFound, - attestedAt: BigInt(raw.attestedAt), - expiresAt: BigInt(raw.expiresAt), - revoked: raw.revoked, -}); - // ───────────────────────────────────────────────────────────────────────── // fetchers — exposed so the publisher dialog can refresh after publish // ───────────────────────────────────────────────────────────────────────── diff --git a/libs/tangle-shared-ui/src/data/graphql/slashProposal.ts b/libs/tangle-shared-ui/src/data/graphql/slashProposal.ts new file mode 100644 index 0000000000..8a68f50d24 --- /dev/null +++ b/libs/tangle-shared-ui/src/data/graphql/slashProposal.ts @@ -0,0 +1,217 @@ +/** + * Pure (wagmi-free) slash-proposal decode + ABI selection. + * + * These pieces are split out of `useSlashing.ts` so they can be unit-tested + * without pulling wagmi/react-query (ESM) into the jest module graph. The hook + * module re-exports everything here, so consumers importing from `useSlashing` + * (or the `graphql` barrel) see no change. + * + * tnt-core 0.19 slimmed the on-chain `SlashProposal` struct — it dropped + * `proposedAt`, `disputeReason`, and `disputedAt` (reconstructable off-chain + * from the `SlashProposed` / `SlashDisputed` event blocks). So legacy/v018 + * chains return the full 14-field tuple and v019 chains return an 11-field + * tuple; the read ABI and the positional decode below must branch on revision + * or every field after `evidence` mis-aligns. + */ + +import type { Address } from 'viem'; +import { zeroAddress } from 'viem'; +import { + getTntCoreRevisionByChainId, + type TntCoreRevision, +} from '@tangle-network/dapp-config/contracts'; +import TANGLE_ABI from '../../abi/tangle'; + +export type SlashStatus = 'Pending' | 'Executed' | 'Cancelled' | 'Disputed'; +export type SlashProposerRole = + | 'ServiceOwner' + | 'BlueprintOwner' + | 'SlashingOrigin' + | 'Unknown'; + +export interface SlashProposal { + id: bigint; + serviceId: bigint; + operator: Address; + proposer: Address; + proposerRole: SlashProposerRole; + slashBps: bigint; + effectiveSlashBps: bigint; + amount: bigint; + effectiveAmount: bigint; + evidence: `0x${string}`; + proposedAt: bigint; + executeAfter: bigint; + status: SlashStatus; + disputeReason: string | null; + cancelReason: string | null; + disputer: Address; + disputeBond: bigint; + disputedAt: bigint; + disputeDeadline: bigint; +} + +export const parseSlashStatus = ( + status: string | number | bigint, +): SlashStatus => { + if (typeof status === 'number' || typeof status === 'bigint') { + switch (Number(status)) { + case 1: + return 'Disputed'; + case 2: + return 'Executed'; + case 3: + return 'Cancelled'; + default: + return 'Pending'; + } + } + + switch (status.toLowerCase()) { + case 'executed': + return 'Executed'; + case 'cancelled': + return 'Cancelled'; + case 'disputed': + return 'Disputed'; + default: + return 'Pending'; + } +}; + +/** + * Full (pre-0.19) `getSlashProposal` return tuple. The synced `TANGLE_ABI` + * carries the SHORT 0.19 tuple, so legacy/v018 chains must decode + * `getSlashProposal` with this full-tuple ABI or every field after `evidence` + * mis-aligns. + */ +export const GET_SLASH_PROPOSAL_V018_ABI = [ + { + type: 'function', + name: 'getSlashProposal', + stateMutability: 'view', + inputs: [{ name: 'slashId', type: 'uint64', internalType: 'uint64' }], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct SlashingLib.SlashProposal', + components: [ + { name: 'serviceId', type: 'uint64', internalType: 'uint64' }, + { name: 'operator', type: 'address', internalType: 'address' }, + { name: 'proposer', type: 'address', internalType: 'address' }, + { name: 'slashBps', type: 'uint16', internalType: 'uint16' }, + { name: 'effectiveSlashBps', type: 'uint16', internalType: 'uint16' }, + { name: 'evidence', type: 'bytes32', internalType: 'bytes32' }, + { name: 'proposedAt', type: 'uint64', internalType: 'uint64' }, + { name: 'executeAfter', type: 'uint64', internalType: 'uint64' }, + { + name: 'status', + type: 'uint8', + internalType: 'enum SlashingLib.SlashStatus', + }, + { name: 'disputeReason', type: 'string', internalType: 'string' }, + { name: 'disputer', type: 'address', internalType: 'address' }, + { name: 'disputeBond', type: 'uint256', internalType: 'uint256' }, + { name: 'disputedAt', type: 'uint64', internalType: 'uint64' }, + { name: 'disputeDeadline', type: 'uint64', internalType: 'uint64' }, + ], + }, + ], + }, +] as const; + +/** + * Selects the ABI for the `getSlashProposal` READ by revision. v019 uses the + * synced (short-tuple) `TANGLE_ABI`; legacy/v018 use the full-tuple fragment. + */ +export const slashProposalReadAbiFor = (chainId: number) => + getTntCoreRevisionByChainId(chainId) === 'v019' + ? TANGLE_ABI + : GET_SLASH_PROPOSAL_V018_ABI; + +export const normalizeOnChainSlashProposal = ( + slashId: bigint, + // viem returns either a named tuple object or a positional array depending on + // whether the ABI components carry names; accept both shapes. + proposal: any, + revision: TntCoreRevision, +): SlashProposal => { + const isV019 = revision === 'v019'; + const serviceId = + proposal?.serviceId !== undefined + ? BigInt(proposal.serviceId.toString()) + : BigInt(proposal?.[0]?.toString() ?? 0); + const operator = (proposal?.operator ?? + proposal?.[1] ?? + zeroAddress) as Address; + const proposer = (proposal?.proposer ?? + proposal?.[2] ?? + zeroAddress) as Address; + const slashBps = BigInt( + proposal?.slashBps?.toString() ?? proposal?.[3]?.toString() ?? 0, + ); + const effectiveSlashBps = BigInt( + proposal?.effectiveSlashBps?.toString() ?? proposal?.[4]?.toString() ?? 0, + ); + const evidence = (proposal?.evidence ?? + proposal?.[5] ?? + '0x') as `0x${string}`; + const proposedAt = isV019 + ? BigInt(proposal?.proposedAt?.toString() ?? 0) + : BigInt( + proposal?.proposedAt?.toString() ?? proposal?.[6]?.toString() ?? 0, + ); + const executeAfter = BigInt( + proposal?.executeAfter?.toString() ?? + (isV019 ? proposal?.[6]?.toString() : proposal?.[7]?.toString()) ?? + 0, + ); + const statusValue = + proposal?.status ?? (isV019 ? proposal?.[7] : proposal?.[8]) ?? 0; + const disputeReason = ( + isV019 + ? (proposal?.disputeReason ?? null) + : (proposal?.disputeReason ?? proposal?.[9] ?? null) + ) as string | null; + const disputer = (proposal?.disputer ?? + (isV019 ? proposal?.[8] : proposal?.[10]) ?? + zeroAddress) as Address; + const disputeBond = BigInt( + proposal?.disputeBond?.toString() ?? + (isV019 ? proposal?.[9]?.toString() : proposal?.[11]?.toString()) ?? + 0, + ); + const disputedAt = isV019 + ? BigInt(proposal?.disputedAt?.toString() ?? 0) + : BigInt( + proposal?.disputedAt?.toString() ?? proposal?.[12]?.toString() ?? 0, + ); + const disputeDeadline = BigInt( + proposal?.disputeDeadline?.toString() ?? + (isV019 ? proposal?.[10]?.toString() : proposal?.[13]?.toString()) ?? + 0, + ); + + return { + id: slashId, + serviceId, + operator, + proposer, + proposerRole: 'Unknown', + slashBps, + effectiveSlashBps, + amount: slashBps, + effectiveAmount: effectiveSlashBps, + evidence, + proposedAt, + executeAfter, + status: parseSlashStatus(statusValue), + disputeReason, + cancelReason: null, + disputer, + disputeBond, + disputedAt, + disputeDeadline, + }; +}; diff --git a/libs/tangle-shared-ui/src/data/graphql/useSlashing.revision.spec.ts b/libs/tangle-shared-ui/src/data/graphql/useSlashing.revision.spec.ts new file mode 100644 index 0000000000..3102d99b85 --- /dev/null +++ b/libs/tangle-shared-ui/src/data/graphql/useSlashing.revision.spec.ts @@ -0,0 +1,155 @@ +/** + * Revision-aware `getSlashProposal` decode coverage. + * + * tnt-core 0.19 slimmed the on-chain `SlashProposal` struct: it dropped + * `proposedAt`, `disputeReason`, and `disputedAt`. So legacy/v018 chains return + * a 14-field tuple and v019 chains return an 11-field tuple. `useSlashing` + * selects the read ABI by revision and `normalizeOnChainSlashProposal` walks the + * two positional layouts. A one-index slip here (e.g. reading `executeAfter` + * from slot 7 on a v019 tuple where it lives at slot 6) silently shows wrong + * dispute/execution deadlines in the UI — these tests pin both layouts. + */ + +// Control the per-chain revision the hook module reads. +import { + normalizeOnChainSlashProposal, + slashProposalReadAbiFor, +} from './slashProposal'; + +const revisionByChainId: Record = { + 1: 'legacy', + 18: 'v018', + 19: 'v019', +}; + +jest.mock('@tangle-network/dapp-config/contracts', () => ({ + __esModule: true, + getTntCoreRevisionByChainId: (chainId: number) => + revisionByChainId[chainId] ?? 'legacy', + getContractsByChainId: () => ({ + tangle: '0x0000000000000000000000000000000000000001', + }), +})); + +const OPERATOR = '0x1111111111111111111111111111111111111111'; +const PROPOSER = '0x2222222222222222222222222222222222222222'; +const DISPUTER = '0x3333333333333333333333333333333333333333'; +const EVIDENCE = + '0x00000000000000000000000000000000000000000000000000000000000000ab'; + +/** + * Full pre-0.19 (legacy / v018) named-tuple shape as viem decodes it against + * the 14-field `GET_SLASH_PROPOSAL_V018_ABI`. + */ +const v018Named = { + serviceId: 7n, + operator: OPERATOR, + proposer: PROPOSER, + slashBps: 250n, + effectiveSlashBps: 200n, + evidence: EVIDENCE, + proposedAt: 1_000n, + executeAfter: 2_000n, + status: 0, // Pending + disputeReason: 'because', + disputer: DISPUTER, + disputeBond: 42n, + disputedAt: 1_500n, + disputeDeadline: 3_000n, +}; + +/** + * Short 0.19 named-tuple shape as viem decodes it against the synced + * `TANGLE_ABI` — `proposedAt`, `disputeReason`, and `disputedAt` are gone. + */ +const v019Named = { + serviceId: 7n, + operator: OPERATOR, + proposer: PROPOSER, + slashBps: 250n, + effectiveSlashBps: 200n, + evidence: EVIDENCE, + executeAfter: 2_000n, + status: 0, + disputer: DISPUTER, + disputeBond: 42n, + disputeDeadline: 3_000n, +}; + +describe('slashProposalReadAbiFor', () => { + it('selects the full-tuple fragment for legacy and v018', () => { + const legacy = slashProposalReadAbiFor(1); + const v018 = slashProposalReadAbiFor(18); + // The v018 fragment carries the dropped fields; assert the tuple length. + const legacyOutputs = ( + legacy[0] as { outputs: [{ components: unknown[] }] } + ).outputs[0].components; + expect(legacyOutputs).toHaveLength(14); + expect(v018).toBe(legacy); + }); + + it('selects the synced short-tuple ABI for v019', () => { + const v019 = slashProposalReadAbiFor(19); + // The synced ABI is a large multi-function array, not the 1-entry fragment. + expect(v019.length).toBeGreaterThan(1); + }); +}); + +describe('normalizeOnChainSlashProposal', () => { + it('decodes the full legacy/v018 tuple including proposedAt and disputedAt', () => { + const result = normalizeOnChainSlashProposal(5n, v018Named, 'v018'); + expect(result.id).toBe(5n); + expect(result.serviceId).toBe(7n); + expect(result.operator).toBe(OPERATOR); + expect(result.slashBps).toBe(250n); + expect(result.effectiveSlashBps).toBe(200n); + expect(result.evidence).toBe(EVIDENCE); + expect(result.proposedAt).toBe(1_000n); + expect(result.executeAfter).toBe(2_000n); + expect(result.status).toBe('Pending'); + expect(result.disputeReason).toBe('because'); + expect(result.disputer).toBe(DISPUTER); + expect(result.disputeBond).toBe(42n); + expect(result.disputedAt).toBe(1_500n); + expect(result.disputeDeadline).toBe(3_000n); + }); + + it('decodes the short v019 tuple with dropped fields defaulted, not misaligned', () => { + const result = normalizeOnChainSlashProposal(5n, v019Named, 'v019'); + // Fields present on v019 must survive. + expect(result.serviceId).toBe(7n); + expect(result.executeAfter).toBe(2_000n); + expect(result.disputer).toBe(DISPUTER); + expect(result.disputeBond).toBe(42n); + expect(result.disputeDeadline).toBe(3_000n); + expect(result.status).toBe('Pending'); + // Dropped-on-v019 fields default rather than reading a shifted slot. + expect(result.proposedAt).toBe(0n); + expect(result.disputeReason).toBeNull(); + expect(result.disputedAt).toBe(0n); + }); + + it('reads the v019 positional tuple without one-index slippage', () => { + // Positional (array) form as viem returns when component names are absent. + const positional = [ + 7n, // serviceId + OPERATOR, // operator + PROPOSER, // proposer + 250n, // slashBps + 200n, // effectiveSlashBps + EVIDENCE, // evidence + 2_000n, // executeAfter (slot 6 on v019; slot 7 on v018) + 0, // status + DISPUTER, // disputer + 42n, // disputeBond + 3_000n, // disputeDeadline + ]; + const result = normalizeOnChainSlashProposal(9n, positional, 'v019'); + expect(result.executeAfter).toBe(2_000n); + expect(result.disputer).toBe(DISPUTER); + expect(result.disputeBond).toBe(42n); + expect(result.disputeDeadline).toBe(3_000n); + expect(result.proposedAt).toBe(0n); + expect(result.disputedAt).toBe(0n); + }); +}); diff --git a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts index 44be325c7a..79e37af43b 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts @@ -11,7 +11,6 @@ import { useAccount, useChainId, usePublicClient } from 'wagmi'; import { getContractsByChainId, getTntCoreRevisionByChainId, - type TntCoreRevision, } from '@tangle-network/dapp-config/contracts'; import { Address, type Hash, isAddress, zeroAddress } from 'viem'; import TANGLE_ABI from '../../abi/tangle'; @@ -25,16 +24,30 @@ import useNetworkStore from '../../context/useNetworkStore'; import useContractWrite, { TxStatus as ContractTxStatus, } from '../../hooks/useContractWrite'; +import { + type SlashProposal, + type SlashProposerRole, + type SlashStatus, + parseSlashStatus, + normalizeOnChainSlashProposal, + slashProposalReadAbiFor, +} from './slashProposal'; + +// Re-exported from the pure `slashProposal` module (wagmi-free, unit-tested) so +// existing importers of these symbols from `useSlashing` keep working. +export { + parseSlashStatus, + normalizeOnChainSlashProposal, + slashProposalReadAbiFor, +} from './slashProposal'; +export type { + SlashProposal, + SlashProposerRole, + SlashStatus, +} from './slashProposal'; export const SLASH_EXECUTION_BUFFER_SECONDS = 15; -// Slash status enum -export type SlashStatus = 'Pending' | 'Executed' | 'Cancelled' | 'Disputed'; -export type SlashProposerRole = - | 'ServiceOwner' - | 'BlueprintOwner' - | 'SlashingOrigin' - | 'Unknown'; export type SlashFilterScope = 'operator' | 'proposer' | 'actor' | 'all'; export type SlashTimelineState = 'done' | 'current' | 'upcoming' | 'skipped'; @@ -53,33 +66,6 @@ export interface SlashTimelineStage { description: string; } -// Slash proposal structure -export interface SlashProposal { - id: bigint; - serviceId: bigint; - operator: Address; - proposer: Address; - proposerRole: SlashProposerRole; - slashBps: bigint; - effectiveSlashBps: bigint; - // Backwards-compatible aliases. Slash values are in bps, not token units. - amount: bigint; - effectiveAmount: bigint; - evidence: `0x${string}`; - proposedAt: bigint; - executeAfter: bigint; - status: SlashStatus; - disputeReason: string | null; - cancelReason: string | null; - // Dispute lifecycle (populated when status === 'Disputed' or after a dispute - // was filed and later resolved). Sourced from getSlashProposal on-chain or - // backfilled from the indexer when available. - disputer: Address; - disputeBond: bigint; - disputedAt: bigint; - disputeDeadline: bigint; -} - export interface ProposableService { serviceId: bigint; blueprintId: bigint; @@ -167,33 +153,6 @@ interface SlashFilterOptions { statuses?: SlashStatus[]; } -// Parse slash status from string or numeric enum value. -const parseSlashStatus = (status: string | number | bigint): SlashStatus => { - if (typeof status === 'number' || typeof status === 'bigint') { - switch (Number(status)) { - case 1: - return 'Disputed'; - case 2: - return 'Executed'; - case 3: - return 'Cancelled'; - default: - return 'Pending'; - } - } - - switch (status.toLowerCase()) { - case 'executed': - return 'Executed'; - case 'cancelled': - return 'Cancelled'; - case 'disputed': - return 'Disputed'; - default: - return 'Pending'; - } -}; - const getSlashProposerRole = ( proposer: string, service: SlashProposalsResponse['SlashProposal'][number]['service'], @@ -761,162 +720,6 @@ const fetchProposableServices = async ( }); }; -/** - * Full (pre-0.19) `getSlashProposal` return tuple. tnt-core 0.19 slimmed the - * on-chain `SlashProposal` struct — it dropped `proposedAt`, `disputeReason`, - * and `disputedAt` (all now reconstructable off-chain from the `SlashProposed` - * / `SlashDisputed` event blocks). The synced `TANGLE_ABI` carries the SHORT - * 0.19 tuple, so legacy/v018 chains must decode `getSlashProposal` with this - * full-tuple ABI or every field after `evidence` mis-aligns. - */ -const GET_SLASH_PROPOSAL_V018_ABI = [ - { - type: 'function', - name: 'getSlashProposal', - stateMutability: 'view', - inputs: [{ name: 'slashId', type: 'uint64', internalType: 'uint64' }], - outputs: [ - { - name: '', - type: 'tuple', - internalType: 'struct SlashingLib.SlashProposal', - components: [ - { name: 'serviceId', type: 'uint64', internalType: 'uint64' }, - { name: 'operator', type: 'address', internalType: 'address' }, - { name: 'proposer', type: 'address', internalType: 'address' }, - { name: 'slashBps', type: 'uint16', internalType: 'uint16' }, - { name: 'effectiveSlashBps', type: 'uint16', internalType: 'uint16' }, - { name: 'evidence', type: 'bytes32', internalType: 'bytes32' }, - { name: 'proposedAt', type: 'uint64', internalType: 'uint64' }, - { name: 'executeAfter', type: 'uint64', internalType: 'uint64' }, - { - name: 'status', - type: 'uint8', - internalType: 'enum SlashingLib.SlashStatus', - }, - { name: 'disputeReason', type: 'string', internalType: 'string' }, - { name: 'disputer', type: 'address', internalType: 'address' }, - { name: 'disputeBond', type: 'uint256', internalType: 'uint256' }, - { name: 'disputedAt', type: 'uint64', internalType: 'uint64' }, - { name: 'disputeDeadline', type: 'uint64', internalType: 'uint64' }, - ], - }, - ], - }, -] as const; - -/** - * Selects the ABI for the `getSlashProposal` READ by revision. v019 uses the - * synced (short-tuple) `TANGLE_ABI`; legacy/v018 use the full-tuple fragment. - */ -const slashProposalReadAbiFor = (chainId: number) => - getTntCoreRevisionByChainId(chainId) === 'v019' - ? TANGLE_ABI - : GET_SLASH_PROPOSAL_V018_ABI; - -const normalizeOnChainSlashProposal = ( - slashId: bigint, - proposal: any, - revision: TntCoreRevision, -): SlashProposal => { - // Full tuple layout (legacy / v018): - // 0 serviceId, 1 operator, 2 proposer, 3 slashBps, 4 effectiveSlashBps, - // 5 evidence, 6 proposedAt, 7 executeAfter, 8 status, 9 disputeReason, - // 10 disputer, 11 disputeBond, 12 disputedAt, 13 disputeDeadline. - // - // v019 slimmed tuple (proposedAt / disputeReason / disputedAt dropped): - // 0 serviceId, 1 operator, 2 proposer, 3 slashBps, 4 effectiveSlashBps, - // 5 evidence, 6 executeAfter, 7 status, 8 disputer, 9 disputeBond, - // 10 disputeDeadline. On v019 we read by NAME (viem returns a named tuple), - // and the dropped fields are left at their event-sourced defaults: - // proposedAt = 0, disputeReason = null, disputedAt = 0. A follow-up backfills - // proposedAt / disputedAt from the `SlashProposed` / `SlashDisputed` event - // blocks and disputeReason from the indexer. - const isV019 = revision === 'v019'; - const serviceId = - proposal?.serviceId !== undefined - ? BigInt(proposal.serviceId.toString()) - : BigInt(proposal?.[0]?.toString() ?? 0); - const operator = (proposal?.operator ?? - proposal?.[1] ?? - zeroAddress) as Address; - const proposer = (proposal?.proposer ?? - proposal?.[2] ?? - zeroAddress) as Address; - const slashBps = BigInt( - proposal?.slashBps?.toString() ?? proposal?.[3]?.toString() ?? 0, - ); - const effectiveSlashBps = BigInt( - proposal?.effectiveSlashBps?.toString() ?? proposal?.[4]?.toString() ?? 0, - ); - const evidence = (proposal?.evidence ?? - proposal?.[5] ?? - '0x') as `0x${string}`; - // Positional fallbacks below diverge by revision: v019 dropped three fields, - // shifting every index after `evidence` down by one (executeAfter 7→6, etc.) - // and removing proposedAt(6)/disputeReason(9)/disputedAt(12) entirely. viem - // returns a named tuple so name access wins; the indices only backstop a - // positional decode. Guard the dropped fields on v019 so a positional read - // never grabs a neighbouring slot. - const proposedAt = isV019 - ? BigInt(proposal?.proposedAt?.toString() ?? 0) - : BigInt( - proposal?.proposedAt?.toString() ?? proposal?.[6]?.toString() ?? 0, - ); - const executeAfter = BigInt( - proposal?.executeAfter?.toString() ?? - (isV019 ? proposal?.[6]?.toString() : proposal?.[7]?.toString()) ?? - 0, - ); - const statusValue = - proposal?.status ?? (isV019 ? proposal?.[7] : proposal?.[8]) ?? 0; - const disputeReason = ( - isV019 - ? (proposal?.disputeReason ?? null) - : (proposal?.disputeReason ?? proposal?.[9] ?? null) - ) as string | null; - const disputer = (proposal?.disputer ?? - (isV019 ? proposal?.[8] : proposal?.[10]) ?? - zeroAddress) as Address; - const disputeBond = BigInt( - proposal?.disputeBond?.toString() ?? - (isV019 ? proposal?.[9]?.toString() : proposal?.[11]?.toString()) ?? - 0, - ); - const disputedAt = isV019 - ? BigInt(proposal?.disputedAt?.toString() ?? 0) - : BigInt( - proposal?.disputedAt?.toString() ?? proposal?.[12]?.toString() ?? 0, - ); - const disputeDeadline = BigInt( - proposal?.disputeDeadline?.toString() ?? - (isV019 ? proposal?.[10]?.toString() : proposal?.[13]?.toString()) ?? - 0, - ); - - return { - id: slashId, - serviceId, - operator, - proposer, - proposerRole: 'Unknown', - slashBps, - effectiveSlashBps, - amount: slashBps, - effectiveAmount: effectiveSlashBps, - evidence, - proposedAt, - executeAfter, - status: parseSlashStatus(statusValue), - disputeReason, - cancelReason: null, - disputer, - disputeBond, - disputedAt, - disputeDeadline, - }; -}; - /** * Hook to fetch slash proposals with lifecycle-aware filtering. */ diff --git a/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts b/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts index c82d7dcdcf..f47bcc5ebf 100644 --- a/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts +++ b/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts @@ -82,6 +82,9 @@ export const useCanScheduleExit = ( // degrade to a safe, non-blocking default: the chain still enforces exit // rules at `scheduleExit` time. Fail closed on `canExit` so we never // render an exit action as available when we cannot verify it. + // TODO(v019): wire getExitStatus-based eligibility so operators on v019 + // chains can schedule exits through the UI (this fail-closed default + // blocks the Schedule-Exit button on every v019 chain until then). if (getTntCoreRevisionByChainId(chainId) === 'v019') { return { canExit: false,