Automated Tangle Audits run (coordinator engine on prod, tnt-core @ 51b392f, src scope). The run stalled at the tail before the adversarial validate/repro phase ran, so these are UNVALIDATED candidates — surfaced so they are not lost. Please triage against source before acting.
Two additional HIGH business-logic candidates (distinct from #199):
Candidate 1 — [business-logic] escrow burn in completeUndelegation over-burns post-delegation beacon rewards
- Severity (self-rated, unvalidated): HIGH
- Location:
src/beacon/ValidatorPodManager.sol:703-756
SETUP: A delegator's beacon pool escrow shares (_delegatorOperatorEscrowShares) are locked at delegateTo, sized escrowShares = _convertToShares(beaconPool, amount) at the beacon price AT DELEGATION TIME. On completeUndelegation the surviving fraction is computed as escrowToRelease = escrowCovered * realizedAssets / depositedCovered, where depositedCovered = counterDelta (the DEPOSITED principal, asset units) and realizedAssets is the post-slash live delegation-pool value. escrowToBurn = escrowCovered - escrowToRelease is then burned from the beacon pool at the CURRENT (post-reward) beacon price (lines 734-742, burnAssets = _convertToAssets(bp, burnShares)).
ATTACK/TRIGGER (no attacker needed — normal sequence harms honest delegator): deposit 32 ETH; delegateTo(op,16 ETH) at 1:1 beacon price → escrowShares=16e18; beacon rewards accrue (+32 ETH up-rebase) so beacon price doubles and those 16e18 escrow shares are now worth 32 ETH; operator suffers a 50% service slash so the delegation live value drops 16→8 ETH. The release fraction is realized/depositedCovered = 8/16 = 0.5, so HALF the escrow shares (8e18) are burned. But 8e18 shares at the doubled price = ~16 ETH of beacon principal destroyed for an 8-ETH economic slash.
OUTCOME: delegator's final withdrawable beacon value is 48 ETH; correct entitlement is 56 ETH (64 ETH total value − 8 ETH slash that must not touch rewards). The delegator permanently loses ~8 ETH of their OWN post-delegation rewards. The error is a units mismatch: the burn fraction is applied to share quantity but the shares are revalued at the current price, so rewards earned on escrowed principal between delegate and slash are burned proportionally with the slash. Verified numerically; magnitude scales with (reward growth × slash bps).
Candidate 2 — [business-logic] createStream overwrite strands prior stream's undistributed funds
- Severity (self-rated, unvalidated): HIGH
- Location:
src/rewards/StreamingPaymentManager.sol:143-181
SETUP: createStream(serviceId, operator, ...) is called once per staker-share payment event for a TTL service (PaymentsDistribution._forwardStakerShare -> ServiceFeeDistributor.distributeServiceFee -> streamingManager.createStream). Each call physically transfers amount tokens/ETH into StreamingPaymentManager BEFORE createStream runs (ServiceFeeDistributor.sol:493-498). Stream state is keyed by (serviceId, operator) as a single struct.
ATTACK/TRIGGER: A TTL service receives a SECOND payment mid-stream while an existing stream is still active and only partially distributed:
- existing: totalAmount=100, distributed=40 (60 tokens still held in contract, owed to delegators / refundable on termination).
- Second createStream computes startTime = block.timestamp (since block.timestamp > svc.createdAt, ServiceFeeDistributor.sol:486-488) and endTime = createdAt+ttl.
- Line 146-148 drips the old stream (fine).
- Line 151 extension branch requires
existing.endTime == startTime; endTime=createdAt+ttl != block.timestamp, so it is SKIPPED.
- Line 159 OVERWRITES streamingPayments[serviceId][operator] with a brand-new struct: distributed=0, totalAmount=newAmount only.
OUTCOME: The previous stream's remaining 60 tokens are permanently orphaned. They are no longer part of any stream's remaining (totalAmount-distributed), so:
- they can never be dripped/distributed to delegators (new struct starts distributed=0 against only the new totalAmount), and
- onServiceTerminated (line 326) and onOperatorLeaving compute refunds from the CURRENT struct only, so the 60 is never refunded to the payer either.
The tokens are stuck in the contract forever, causing loss of delegator rewards and understated refunds. No access control blocks this — it is triggered by normal repeated service payments. The narrow extension guard (endTime == startTime) almost never matches the overwrite condition, making stranding the default outcome for any second mid-stream payment.
Reviewer notes (my quick source check)
- Candidate 1 (ValidatorPodManager escrow): plausible — the escrow release uses
escrowToRelease = escrowCovered * realizedAssets / depositedCovered with a shares-at-delegation vs deposit-value basis; a share/asset basis mismatch across time is a classic over/under-burn source. Worth a careful trace.
- Candidate 2 (StreamingPaymentManager overwrite): weaker than stated —
createStream calls _drip(serviceId, operator) on the existing stream before overwriting (StreamingPaymentManager.sol ~148), and extends in place when endTime == startTime. The finding did not account for that drip. Residual concern: the un-elapsed remainder of a prior stream (dripped only up to block.timestamp) could still be overwritten/stranded when the new stream is not an exact contiguous extension. Confirm whether that path is reachable.
Filed from an internal automated audit; validate phase did not complete, so treat as leads, not confirmed bugs.
Two additional HIGH business-logic candidates (distinct from #199):
Candidate 1 — [business-logic] escrow burn in completeUndelegation over-burns post-delegation beacon rewards
src/beacon/ValidatorPodManager.sol:703-756SETUP: A delegator's beacon pool escrow shares (
_delegatorOperatorEscrowShares) are locked at delegateTo, sizedescrowShares = _convertToShares(beaconPool, amount)at the beacon price AT DELEGATION TIME. On completeUndelegation the surviving fraction is computed asescrowToRelease = escrowCovered * realizedAssets / depositedCovered, wheredepositedCovered = counterDelta(the DEPOSITED principal, asset units) andrealizedAssetsis the post-slash live delegation-pool value.escrowToBurn = escrowCovered - escrowToReleaseis then burned from the beacon pool at the CURRENT (post-reward) beacon price (lines 734-742,burnAssets = _convertToAssets(bp, burnShares)).ATTACK/TRIGGER (no attacker needed — normal sequence harms honest delegator): deposit 32 ETH; delegateTo(op,16 ETH) at 1:1 beacon price → escrowShares=16e18; beacon rewards accrue (+32 ETH up-rebase) so beacon price doubles and those 16e18 escrow shares are now worth 32 ETH; operator suffers a 50% service slash so the delegation live value drops 16→8 ETH. The release fraction is realized/depositedCovered = 8/16 = 0.5, so HALF the escrow shares (8e18) are burned. But 8e18 shares at the doubled price = ~16 ETH of beacon principal destroyed for an 8-ETH economic slash.
OUTCOME: delegator's final withdrawable beacon value is 48 ETH; correct entitlement is 56 ETH (64 ETH total value − 8 ETH slash that must not touch rewards). The delegator permanently loses ~8 ETH of their OWN post-delegation rewards. The error is a units mismatch: the burn fraction is applied to share quantity but the shares are revalued at the current price, so rewards earned on escrowed principal between delegate and slash are burned proportionally with the slash. Verified numerically; magnitude scales with (reward growth × slash bps).
Candidate 2 — [business-logic] createStream overwrite strands prior stream's undistributed funds
src/rewards/StreamingPaymentManager.sol:143-181SETUP:
createStream(serviceId, operator, ...)is called once per staker-share payment event for a TTL service (PaymentsDistribution._forwardStakerShare -> ServiceFeeDistributor.distributeServiceFee -> streamingManager.createStream). Each call physically transfersamounttokens/ETH into StreamingPaymentManager BEFORE createStream runs (ServiceFeeDistributor.sol:493-498). Stream state is keyed by (serviceId, operator) as a single struct.ATTACK/TRIGGER: A TTL service receives a SECOND payment mid-stream while an existing stream is still active and only partially distributed:
existing.endTime == startTime; endTime=createdAt+ttl != block.timestamp, so it is SKIPPED.OUTCOME: The previous stream's remaining 60 tokens are permanently orphaned. They are no longer part of any stream's
remaining(totalAmount-distributed), so:The tokens are stuck in the contract forever, causing loss of delegator rewards and understated refunds. No access control blocks this — it is triggered by normal repeated service payments. The narrow extension guard (
endTime == startTime) almost never matches the overwrite condition, making stranding the default outcome for any second mid-stream payment.Reviewer notes (my quick source check)
escrowToRelease = escrowCovered * realizedAssets / depositedCoveredwith a shares-at-delegation vs deposit-value basis; a share/asset basis mismatch across time is a classic over/under-burn source. Worth a careful trace.createStreamcalls_drip(serviceId, operator)on the existing stream before overwriting (StreamingPaymentManager.sol ~148), and extends in place whenendTime == startTime. The finding did not account for that drip. Residual concern: the un-elapsed remainder of a prior stream (dripped only up toblock.timestamp) could still be overwritten/stranded when the new stream is not an exact contiguous extension. Confirm whether that path is reachable.Filed from an internal automated audit; validate phase did not complete, so treat as leads, not confirmed bugs.