Skip to content

Refactor sign tx#11507

Draft
ak88 wants to merge 7 commits intomasterfrom
refactor/sign-tx
Draft

Refactor sign tx#11507
ak88 wants to merge 7 commits intomasterfrom
refactor/sign-tx

Conversation

@ak88
Copy link
Copy Markdown
Contributor

@ak88 ak88 commented May 6, 2026

Refactor sign tx out of the consensus module and instead just listen for event inside the manager.

@ak88 ak88 added the xdc label May 6, 2026
@ak88 ak88 requested a review from batrr May 6, 2026 13:17
@ak88
Copy link
Copy Markdown
Contributor Author

ak88 commented May 6, 2026

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

Claude finished @ak88's task in 2m 57s —— View job


Review: Refactor sign tx

  • Gather context
  • Read changed files
  • Analyze changes for correctness, security, and performance
  • Post findings

The refactor moves signing-transaction logic out of the XdcHotStuff consensus loop and into SignTransactionManager which now listens directly on BlockAddedToMain. The architecture change is sound — hooking the blockchain event is a better place for this than the tight consensus loop. However there is one critical behavioral regression introduced during the move, plus a few high/medium issues.

Critical

# Location Issue
1 SignTransactionManager.cs:76 Inverted MergeSignRange condition — old code signs when Number % MergeSignRange == 0; new code returns at that point and signs on every other block. Confirmed against PenaltyHandler and XdcRewardCalculator which both treat multiples of MergeSignRange as the action boundary. This is a pure refactor, so behavior must not change.

High

# Location Issue
2 SignTransactionManager.cs:86 Fire-and-forget swallows async exceptions. _ = SubmitTransactionSign(...) discards the returned Task. Signer failures, tx-pool rejections, and any exception inside will be silently lost. robustness.md: "Only omit await when fire-and-forget is the documented intent." Fix: chain .ContinueWith(t => _logger.Error(...), OnlyOnFaulted) or use a small async-safe wrapper.

Medium

# Location Issue
3 SignTransactionManager.cs:71 ExtraConsensusData not null-checked before .BlockRound. An unhandled NullReferenceException thrown from an event callback propagates into blockchain core code. XdcHotStuff.OnNewHeadBlock guards the same field explicitly — this handler should too.
4 SignTransactionManager.cs:73 spec == null should be spec is null per coding-style.md.

Low

# Location Issue
5 SignTransactionManager.cs:31 _alreadySigned is assigned once; should be readonly.
6 XdcModule.cs:136 CreateSignTransactionManager factory method is now dead code — the registration changed to .AddSingleton<ISignTransactionManager, SignTransactionManager>() which resolves the constructor automatically.

What's good

  • Subscribing to BlockAddedToMain is the right event for this — fires once per canonical block, cleanly decoupled from the consensus round loop.
  • Using AssociativeKeyCache<ValueHash256> for dedup is an improvement over the old monotonic _highestSignTxNumber (handles reorg scenarios correctly; the cache is thread-safe by design).
  • IDisposable.Dispose correctly unsubscribes the event handler, preventing leaks.
  • XdcHotStuff is now simpler and has one fewer dependency.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

Claude finished @ak88's task in 2m 7s —— View job


Review: Refactor sign tx

  • Read rule files
  • Read changed files
  • Analyze diff and post findings

The refactor moves signing-transaction logic out of XdcHotStuff and into SignTransactionManager via a BlockAddedToMain event subscription. The direction is good — the consensus runner shouldn't own this concern — but there are issues in the implementation.

High

SignTransactionManager.cs:86 — fire-and-forget poisons the deduplication cache

_alreadySigned.Set(xdcHeader.Hash) is called before the async sign attempt, then the task is discarded with _ = SubmitTransactionSign(...). If the signing throws (key unavailable, signer error, etc.) the exception is silently swallowed and the hash is already cached, so the block can never be retried on a subsequent BlockAddedToMain event. This is a silent, permanent miss. See inline comment for a fix sketch.

Medium

XdcModule.cs:136CreateSignTransactionManager factory is dead code

The registration was changed to .AddSingleton<ISignTransactionManager, SignTransactionManager>() (Autofac constructor injection), so this private factory is never invoked. Delete it.

ISignTransactionManager interface is now external-facing dead weight

After this refactor, nothing outside SignTransactionManager resolves ISignTransactionManager from the DI container or calls SubmitTransactionSign. The interface and its registration serve no abstraction purpose. Either:

  • Remove it and register SignTransactionManager as a concrete singleton (so the container constructs and manages its lifetime including IDisposable cleanup), or
  • Keep it if testability/mocking of the external call is still desired, but document why.

Low

SignTransactionManager.cs:73== null should be is null

Repo style requires is null / is not null. See inline comment.


Verdict: not yet mergeable. The High finding (silent exception + cache poisoning on sign failure) and the dead-code Medium findings should be addressed first.

if (spec == null)
return;

if (xdcHeader.Number % spec.MergeSignRange == 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: inverted signing condition

The old code in XdcHotStuff signed when Number % MergeSignRange == 0:

if (_highestSignTxNumber < roundParent.Number && (roundParent.Number % spec.MergeSignRange == 0))
{ // sign }

PenaltyHandler and XdcRewardCalculator both confirm the semantic: actions occur at multiples of MergeSignRange. The new code does the opposite — it returns early (skips) at multiples and signs on every other block, which is a complete behavioral inversion.

Suggested change
if (xdcHeader.Number % spec.MergeSignRange == 0)
if (xdcHeader.Number % spec.MergeSignRange != 0)
return;

if (IsMasternode(snapshot, _signer.Address))
{
_alreadySigned.Set(xdcHeader.Hash);
_ = SubmitTransactionSign(xdcHeader, spec);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: fire-and-forget swallows async exceptions

_ = SubmitTransactionSign(...) discards the Task, so any exception thrown inside SubmitTransactionSign (signer failure, tx-pool error, etc.) is silently lost. Per robustness.md: "A missing await on an async call silently discards the result. Only omit await when fire-and-forget is the documented intent."

This handler fires on an event callback so it cannot be async directly — the conventional fix is a helper that logs on failure:

Suggested change
_ = SubmitTransactionSign(xdcHeader, spec);
_ = SubmitTransactionSign(xdcHeader, spec).ContinueWith(
t => _logger.Error("Failed to submit sign transaction", t.Exception),
System.Threading.Tasks.TaskContinuationOptions.OnlyOnFaulted);

if (_alreadySigned.Contains(xdcHeader.Hash))
return;

ulong round = xdcHeader.ExtraConsensusData.BlockRound;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: potential NullReferenceException

ExtraConsensusData is not null-checked before accessing .BlockRound. XdcHotStuff.OnNewHeadBlock throws explicitly when it's null; this event handler would throw an unhandled exception from the blockchain core instead.

Suggested change
ulong round = xdcHeader.ExtraConsensusData.BlockRound;
if (xdcHeader.ExtraConsensusData is null)
return;
ulong round = xdcHeader.ExtraConsensusData.BlockRound;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is expected to be a xdc header


ulong round = xdcHeader.ExtraConsensusData.BlockRound;
IXdcReleaseSpec spec = _specProvider.GetXdcSpec(xdcHeader, round);
if (spec == null)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: use is null instead of == null

Per coding-style.md: "Use is null and is not null instead of == null and != null".

Suggested change
if (spec == null)
if (spec is null)
return;

private readonly IBlockTree _blockTree;
private readonly ISnapshotManager _snapshotManager;
private readonly ISpecProvider _specProvider;
private AssociativeKeyCache<ValueHash256> _alreadySigned = new (128);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: field should be readonly

_alreadySigned is assigned once in the constructor and never reassigned; it should be readonly.

Suggested change
private AssociativeKeyCache<ValueHash256> _alreadySigned = new (128);
private readonly AssociativeKeyCache<ValueHash256> _alreadySigned = new(128);


private ISnapshotManager CreateSnapshotManager([KeyFilter(XdcRocksDbConfigFactory.XdcSnapshotDbName)] IDb db, IBlockTree blockTree, IMasternodeVotingContract votingContract, ISpecProvider specProvider) => new SnapshotManager(db, blockTree, votingContract, specProvider);
private ISignTransactionManager CreateSignTransactionManager(ISigner signer, ITxPool txPool, ILogManager logManager) => new SignTransactionManager(signer, txPool, logManager.GetClassLogger<SignTransactionManager>());
private ISignTransactionManager CreateSignTransactionManager(ISigner signer, ITxPool txPool, IBlockTree blockTree, ISnapshotManager snapshotManager, ISpecProvider specProvider, ILogManager logManager) => new SignTransactionManager(signer, txPool, blockTree, snapshotManager, specProvider, logManager.GetClassLogger<SignTransactionManager>());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: dead code

CreateSignTransactionManager is no longer called — the registration changed to .AddSingleton<ISignTransactionManager, SignTransactionManager>() which lets Autofac resolve the constructor directly. This method can be deleted.

if (IsMasternode(snapshot, _signer.Address))
{
_alreadySigned.Set(xdcHeader.Hash);
_ = SubmitTransactionSign(xdcHeader, spec);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — fire-and-forget swallows exceptions; failed sign is silently skipped forever

_alreadySigned.Set is called before the async sign attempt. If SubmitTransactionSign throws (signer unavailable, key error, etc.), the exception is swallowed and the hash remains in the cache — so this block can never be retried.

Because OnBlockAddedToMain is a synchronous event handler you cannot await here directly, but you should at minimum log the failure and avoid poisoning the cache:

Suggested change
_ = SubmitTransactionSign(xdcHeader, spec);
if (IsMasternode(snapshot, _signer.Address))
{
_alreadySigned.Set(xdcHeader.Hash);
_ = SubmitTransactionSign(xdcHeader, spec).ContinueWith(t =>
{
if (t.IsFaulted)
{
_alreadySigned.Delete(xdcHeader.Hash); // allow retry on next event
_logger.Error($"SubmitTransactionSign failed for block {xdcHeader.Hash}", t.Exception!.InnerException);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}

(Or factor the fire-and-forget into a small async Task SignAsync(...) method with a try/catch that handles removal and logging, which avoids the lambda capturing overhead.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we fail to submit our transaction it is fine, just ignore it. Dont want to retry it.


ulong round = xdcHeader.ExtraConsensusData.BlockRound;
IXdcReleaseSpec spec = _specProvider.GetXdcSpec(xdcHeader, round);
if (spec == null)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — style: use is null not == null

Per the repo coding style (coding-style.md): "Use is null and is not null instead of == null and != null".

Suggested change
if (spec == null)
if (spec is null)


private ISnapshotManager CreateSnapshotManager([KeyFilter(XdcRocksDbConfigFactory.XdcSnapshotDbName)] IDb db, IBlockTree blockTree, IMasternodeVotingContract votingContract, ISpecProvider specProvider) => new SnapshotManager(db, blockTree, votingContract, specProvider);
private ISignTransactionManager CreateSignTransactionManager(ISigner signer, ITxPool txPool, ILogManager logManager) => new SignTransactionManager(signer, txPool, logManager.GetClassLogger<SignTransactionManager>());
private ISignTransactionManager CreateSignTransactionManager(ISigner signer, ITxPool txPool, IBlockTree blockTree, ISnapshotManager snapshotManager, ISpecProvider specProvider, ILogManager logManager) => new SignTransactionManager(signer, txPool, blockTree, snapshotManager, specProvider, logManager.GetClassLogger<SignTransactionManager>());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — dead code: this factory method is no longer wired to any DI registration

After the refactor, line 104 registers SignTransactionManager directly via Autofac constructor injection:

.AddSingleton<ISignTransactionManager, SignTransactionManager>()

This CreateSignTransactionManager factory is never called. Delete it.

Similarly, ISignTransactionManager is now only used internally within SignTransactionManager itself — nothing external resolves it from the container. Consider whether the interface (and the DI registration) still serves a purpose, or if SignTransactionManager can simply be registered as a concrete IDisposable singleton activated by the container. Fix this →

@ak88 ak88 force-pushed the refactor/sign-tx branch from 42e6dc8 to fee923a Compare May 6, 2026 14:07
@ak88
Copy link
Copy Markdown
Contributor Author

ak88 commented May 6, 2026

@claude again

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

Claude finished @ak88's task in 3m 19s —— View job


Re-review: Refactor sign tx

  • Gather context and author responses
  • Re-read changed files
  • Re-analyze remaining issues
  • Post findings

The previous review issues have been addressed — the inverted condition is fixed, is null is used, _alreadySigned is readonly, the dead factory method is deleted, and the fire-and-forget now logs on faulted (per ak88: no retry wanted). Both author clarifications are accepted.

However, a silent regression slipped in during the refactor of SubmitTransactionSign itself:

Critical: missing transaction.Hash assignment

File: SignTransactionManager.cs:49

The old code in master:

await signer.Sign(transaction);

transaction.Hash = transaction.CalculateHash();   // ← REMOVED

AcceptTxResult added = txPool.SubmitTx(transaction, TxHandlingOptions.PersistentBroadcast);

The new code drops that line entirely. Signer.Sign(Transaction tx) only sets tx.Signature; it does not compute or assign tx.Hash. TxPool.SubmitTx then dereferences tx.Hash! with null-forgiving operators at several points (_retryCache.Received(tx.Hash!), relevantPool.TryInsert(tx.Hash!, ...), etc.), so submitting with a null hash will throw a NullReferenceException and the pool insertion will fail entirely. TxSealer.Seal is the normal component that sets tx.Hash = tx.CalculateHash(), but it is not in this code path.

Fix — restore the assignment after signing:

await _signer.Sign(transaction);
transaction.Hash = transaction.CalculateHash();   // must recalculate after signature is set

AcceptTxResult added = _txPool.SubmitTx(transaction, TxHandlingOptions.PersistentBroadcast);

Fix this →


Everything else looks good. Once this one line is restored the PR is otherwise clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant