diff --git a/apps/tangle-cloud/src/components/binaryUpgrade/BlueprintVersionsPanel.tsx b/apps/tangle-cloud/src/components/binaryUpgrade/BlueprintVersionsPanel.tsx index 7cab4cfaf..710a42c98 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/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx index 1f84b4c61..e51cfaa6d 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 de7e630f3..3708440eb 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 b725f3677..853751041 100644 --- a/libs/dapp-config/src/contracts.ts +++ b/libs/dapp-config/src/contracts.ts @@ -143,13 +143,31 @@ 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`; `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` 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 efa42bdaa..ed008fa6d 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 1629aab04..8851d8091 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 ae58b383a..7c4806e65 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 cc2c5b4f2..d477f07c2 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 d3390398e..1e80dbfb9 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 8f6a62a51..74f1dd0c6 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/binaryVersion.ts b/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts new file mode 100644 index 000000000..68e5864d4 --- /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 025202aa1..721b32d60 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/readBlueprintCore.ts @@ -6,11 +6,46 @@ 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' }, + // 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' }, + ], + }, + ], + }, +] 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 +93,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.revision.spec.ts b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.revision.spec.ts new file mode 100644 index 000000000..32eae979e --- /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 d50157491..e34c69a23 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts @@ -14,25 +14,29 @@ import { useChainId, usePublicClient } from 'wagmi'; 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}`; - binaryUri: string; - 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, @@ -74,42 +78,6 @@ const auditorRegistryAddressFor = (chainId: number): Address | 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, - attestationHash: raw.attestationHash, - publishedAt: BigInt(raw.publishedAt), - deprecated: raw.deprecated, -}); - -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, - 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 // ───────────────────────────────────────────────────────────────────────── @@ -134,13 +102,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 +135,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 +239,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 +301,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/slashProposal.ts b/libs/tangle-shared-ui/src/data/graphql/slashProposal.ts new file mode 100644 index 000000000..8a68f50d2 --- /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 000000000..3102d99b8 --- /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 68338cbaf..79e37af43 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useSlashing.ts @@ -8,7 +8,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAccount, useChainId, usePublicClient } from 'wagmi'; -import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { + getContractsByChainId, + getTntCoreRevisionByChainId, +} from '@tangle-network/dapp-config/contracts'; import { Address, type Hash, isAddress, zeroAddress } from 'viem'; import TANGLE_ABI from '../../abi/tangle'; import { @@ -21,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'; @@ -49,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; @@ -163,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'], @@ -451,12 +414,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,79 +720,6 @@ const fetchProposableServices = async ( }); }; -const normalizeOnChainSlashProposal = ( - slashId: bigint, - proposal: any, -): SlashProposal => { - // Tuple layout from getSlashProposal (tnt-core v0.13.0): - // 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. - 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 = BigInt( - proposal?.proposedAt?.toString() ?? proposal?.[6]?.toString() ?? 0, - ); - const executeAfter = BigInt( - proposal?.executeAfter?.toString() ?? proposal?.[7]?.toString() ?? 0, - ); - const statusValue = proposal?.status ?? proposal?.[8] ?? 0; - const disputeReason = (proposal?.disputeReason ?? proposal?.[9] ?? null) as - | string - | null; - const disputer = (proposal?.disputer ?? - 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, - ); - const disputeDeadline = BigInt( - proposal?.disputeDeadline?.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. */ @@ -955,12 +852,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 +1110,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 +1118,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 578db0f4e..f47bcc5eb 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,25 @@ 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. + // 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, + 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], });