Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions NArk.Core/Blockchain/NBXplorerBlockchain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,15 @@ public async Task<bool> BroadcastPackageAsync(Transaction parent, Transaction ch
{
try
{
// maxfeerate=0 (unlimited) + maxburnamount=21e6 so Ark P2A anchor
// outputs (non-zero value, non-standard script) are not rejected by
// Bitcoin Core's burn-output check (default maxburnamount=0).
var response = await _explorerClient.RPCClient.SendCommandAsync(
"submitpackage",
cancellationToken,
new object[] { new[] { parent.ToHex(), child.ToHex() } });
new[] { parent.ToHex(), child.ToHex() },
0m,
21_000_000m);

if (response.Error is not null)
{
Expand All @@ -146,14 +151,41 @@ public async Task<bool> BroadcastPackageAsync(Transaction parent, Transaction ch

private async Task<bool> BroadcastSequentialFallbackAsync(Transaction parent, Transaction child, CancellationToken ct)
{
var parentOk = await BroadcastAsync(parent, ct);
// Use direct RPC for the parent so we can pass maxburnamount — NBXplorer's
// HTTP broadcast endpoint does not expose that parameter.
var parentOk = await BroadcastRpcAsync(parent, ct);
if (!parentOk) return false;
var childOk = await BroadcastAsync(child, ct);
if (!childOk)
_logger?.LogDebug("Sequential fallback: child CPFP broadcast failed, but parent was accepted");
return true;
}

// Broadcasts via direct Bitcoin Core RPC with maxburnamount set so Ark P2A
// anchor outputs are not rejected by the burn-output check.
private async Task<bool> BroadcastRpcAsync(Transaction tx, CancellationToken ct)
{
try
{
var response = await _explorerClient.RPCClient.SendCommandAsync(
"sendrawtransaction", ct,
tx.ToHex(),
0m,
21_000_000m);
if (response.Error is not null)
{
_logger?.LogWarning("Broadcast failed for tx {Txid}: {Error}", tx.GetHash(), response.Error.Message);
return false;
}
return true;
}
catch (Exception ex)
{
_logger?.LogWarning(0, ex, "Failed to broadcast tx {Txid}", tx.GetHash());
return false;
}
}

// ── Tx status (RPC getrawtransaction) ────────────────────────────

public async Task<TxStatus> GetTxStatusAsync(uint256 txid, CancellationToken cancellationToken = default)
Expand Down
7 changes: 7 additions & 0 deletions NArk.Core/Exit/TreeTxNotSignedException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace NArk.Core.Exit;

/// <summary>
/// Thrown while parsing a tree virtual tx for broadcast when its input is missing the
/// MuSig2 taproot key-path signature (<c>PSBT_IN_TAP_KEY_SIG</c>).
/// </summary>
internal sealed class TreeTxNotSignedException(string message) : Exception(message);
35 changes: 31 additions & 4 deletions NArk.Core/Services/UnilateralExitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,30 @@ await UpdateSession(session with
// tree txs as PSBT-encoded strings (the same format that
// BatchSession parses across the rest of the codebase) —
// not raw consensus-encoded transactions. Parse + extract.
var tx = ParseVirtualTx(vtx.Hex, network, vtx.Type);
Transaction tx;
try
{
tx = ParseVirtualTx(vtx.Hex, network, vtx.Type);
}
catch (TreeTxNotSignedException ex)
{
// Preconfirmed VTXO: arkd hasn't co-signed the tree yet (the batch round
// hasn't fired). Re-fetch the branch — the stored copy may have been cached
// while the VTXO was preconfirmed and is therefore unsigned — then leave the
// session in Broadcasting and retry on the next poll, instead of failing.
// Mirrors go-sdk's ErrWaitingForConfirmation retry loop.
logger?.LogInformation(
"Tree tx {Txid} not yet co-signed for session {SessionId}; refreshing branch and waiting for the batch round ({Reason})",
vtx.Txid, session.Id, ex.Message);
await virtualTxService.FetchAndStoreBranchAsync(
vtxoOutpoint, VirtualTxMode.Full, forceRefresh: true, ct);
await UpdateSession(session with
{
NextTxIndex = i,
UpdatedAt = DateTimeOffset.UtcNow
}, ct);
return;
}
var success = await BroadcastWithCpfpAsync(tx, ct);

if (!success)
Expand Down Expand Up @@ -841,10 +864,14 @@ private static Transaction ParseVirtualTx(string hex, Network network, ChainedTx
var sig = psbt.Inputs[i].TaprootKeySignature;
if (sig is null)
{
throw new InvalidOperationException(
// Expected for a preconfirmed VTXO: arkd co-signs the tree only during a
// batch round, so the signature is absent until the batch fires. Surface a
// typed, transient signal so ProgressBroadcastingAsync can re-fetch and wait
// instead of failing the exit. See TreeTxNotSignedException.
throw new TreeTxNotSignedException(
$"Tree tx {tx.GetHash()} input {i} is missing the MuSig2 " +
"taproot key-path signature (PSBT_IN_TAP_KEY_SIG); arkd should " +
"have populated it during the batch round.");
"taproot key-path signature (PSBT_IN_TAP_KEY_SIG); arkd populates it " +
"during the batch round, so the VTXO is likely still preconfirmed.");
}
// The `true` flag tells NBitcoin these bytes are stack
// pushes, not a pre-serialized witness — same idiom as
Expand Down
16 changes: 13 additions & 3 deletions NArk.Core/Services/VirtualTxService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,23 @@ public class VirtualTxService(
/// Fetch and store the virtual tx branch for a VTXO.
/// In Lite mode, stores only txids and expiry. In Full mode, also fetches and stores raw tx hex.
/// </summary>
/// <param name="vtxoOutpoint">The VTXO whose virtual-tx chain to fetch.</param>
/// <param name="mode">Lite stores txids + expiry only; Full also fetches raw tx hex.</param>
/// <param name="forceRefresh">
/// When <c>true</c>, re-fetch from the indexer even if a branch is already stored, overwriting it.
/// Needed when a previously stored branch is stale — e.g. it was fetched while the VTXO was still
/// preconfirmed, so the tree-tx PSBTs lacked arkd's MuSig2 co-signature; once the batch round
/// commits the VTXO, a forced re-fetch picks up the signed versions.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task FetchAndStoreBranchAsync(
OutPoint vtxoOutpoint,
VirtualTxMode mode = VirtualTxMode.Full,
bool forceRefresh = false,
CancellationToken cancellationToken = default)
{
// Skip if we already have a branch for this VTXO
if (await storage.HasBranchAsync(vtxoOutpoint, cancellationToken))
// Skip if we already have a branch for this VTXO, unless a refresh is forced.
if (!forceRefresh && await storage.HasBranchAsync(vtxoOutpoint, cancellationToken))
{
logger?.LogDebug("Branch already exists for VTXO {Outpoint}, skipping fetch", vtxoOutpoint);
return;
Expand Down Expand Up @@ -119,7 +129,7 @@ public async Task EnsureHexPopulatedAsync(
if (branch.Count == 0)
{
// No branch stored — fetch everything in Full mode
await FetchAndStoreBranchAsync(vtxoOutpoint, VirtualTxMode.Full, cancellationToken);
await FetchAndStoreBranchAsync(vtxoOutpoint, VirtualTxMode.Full, cancellationToken: cancellationToken);
return;
}

Expand Down
7 changes: 5 additions & 2 deletions NArk.Core/Services/VtxoChainAutoFetchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,12 @@ private async Task ProcessQueueAsync(CancellationToken cancellationToken)
{
// FetchAndStoreBranchAsync is idempotent: it short-
// circuits on the storage's HasBranchAsync check, so
// duplicate events for the same VTXO are cheap.
// duplicate events for the same VTXO are cheap. A branch
// cached here while the VTXO is still preconfirmed carries
// unsigned tree-tx PSBTs; UnilateralExitService force-
// refreshes it once the batch round co-signs the tree.
await virtualTxService.FetchAndStoreBranchAsync(
vtxo.OutPoint, options.Value.DefaultMode, cancellationToken);
vtxo.OutPoint, options.Value.DefaultMode, cancellationToken: cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Expand Down
Loading
Loading