Skip to content

Commit 3e55639

Browse files
committed
feat: add unilateral exit support with virtual tx storage and watchtower
Implements the full unilateral exit pipeline for NNark: Transport: Add GetVtxoChainAsync, GetVirtualTxsAsync, GetVtxoTreeAsync to IClientTransport with gRPC and REST implementations. Storage: VirtualTx/VtxoBranch/ExitSession entities with EF Core storage. Per-tx model deduplicates shared tree nodes across sibling VTXOs. Services: - VirtualTxService: fetch/store/prune virtual tx branches (Lite/Full modes) - UnilateralExitService: orchestrate exit state machine (Broadcasting → AwaitingCsvDelay → Claimable → Claiming → Completed) - ExitWatchtowerService: detect partial tree broadcasts, auto-respond - P2ACpfpBuilder: build v3 CPFP children for 1p1c package relay Integration: - IOnchainBroadcaster with NBXplorer and Esplora implementations - PostBatchVirtualTxFetchHandler: auto-fetch exit data on VTXO receive - PostSpendVirtualTxPruneHandler: auto-prune on VTXO spend - AddUnilateralExit() DI extension method Tests: 12 new unit tests for VirtualTxService and P2ACpfpBuilder.
1 parent fb26ee7 commit 3e55639

36 files changed

Lines changed: 2577 additions & 0 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using NBitcoin;
2+
3+
namespace NArk.Abstractions.Blockchain;
4+
5+
/// <summary>
6+
/// Broadcasts Bitcoin transactions, including v3 package relay.
7+
/// </summary>
8+
public interface IOnchainBroadcaster
9+
{
10+
/// <summary>
11+
/// Broadcast a single transaction.
12+
/// </summary>
13+
Task<bool> BroadcastAsync(Transaction tx, CancellationToken cancellationToken = default);
14+
15+
/// <summary>
16+
/// Broadcast a 1p1c package (parent + CPFP child) via submitpackage.
17+
/// </summary>
18+
Task<bool> BroadcastPackageAsync(Transaction parent, Transaction child, CancellationToken cancellationToken = default);
19+
20+
/// <summary>
21+
/// Get transaction status on-chain.
22+
/// </summary>
23+
Task<TxStatus> GetTxStatusAsync(uint256 txid, CancellationToken cancellationToken = default);
24+
25+
/// <summary>
26+
/// Estimate fee rate for the given confirmation target.
27+
/// </summary>
28+
Task<FeeRate> EstimateFeeRateAsync(int confirmTarget = 6, CancellationToken cancellationToken = default);
29+
}
30+
31+
/// <summary>
32+
/// On-chain transaction status.
33+
/// </summary>
34+
public record TxStatus(bool Confirmed, uint? BlockHeight, bool InMempool);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace NArk.Abstractions.Exit;
2+
3+
/// <summary>
4+
/// Tracks the state of a unilateral exit for a single VTXO.
5+
/// </summary>
6+
public record ExitSession(
7+
string Id,
8+
string VtxoTxid,
9+
uint VtxoVout,
10+
string WalletId,
11+
string ClaimAddress,
12+
ExitSessionState State,
13+
int NextTxIndex,
14+
string? ClaimTxid,
15+
DateTimeOffset CreatedAt,
16+
DateTimeOffset UpdatedAt,
17+
string? FailReason);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace NArk.Abstractions.Exit;
2+
3+
/// <summary>
4+
/// State machine for unilateral exit sessions.
5+
/// </summary>
6+
public enum ExitSessionState
7+
{
8+
/// <summary>Broadcasting tree txs root-to-leaf.</summary>
9+
Broadcasting = 0,
10+
11+
/// <summary>All tree txs confirmed, waiting for CSV timelock to expire.</summary>
12+
AwaitingCsvDelay = 1,
13+
14+
/// <summary>CSV expired, ready to claim funds on-chain.</summary>
15+
Claimable = 2,
16+
17+
/// <summary>Claim tx broadcast, awaiting confirmation.</summary>
18+
Claiming = 3,
19+
20+
/// <summary>Claim tx confirmed. Exit complete.</summary>
21+
Completed = 4,
22+
23+
/// <summary>Unrecoverable error.</summary>
24+
Failed = 5
25+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using NBitcoin;
2+
3+
namespace NArk.Abstractions.Exit;
4+
5+
/// <summary>
6+
/// Storage for unilateral exit sessions.
7+
/// </summary>
8+
public interface IExitSessionStorage
9+
{
10+
Task UpsertAsync(ExitSession session, CancellationToken cancellationToken = default);
11+
Task<ExitSession?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
12+
Task<ExitSession?> GetByVtxoAsync(OutPoint vtxoOutpoint, CancellationToken cancellationToken = default);
13+
Task<IReadOnlyList<ExitSession>> GetByStateAsync(ExitSessionState state, CancellationToken cancellationToken = default);
14+
Task<IReadOnlyList<ExitSession>> GetActiveSessionsAsync(string? walletId = null, CancellationToken cancellationToken = default);
15+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using NBitcoin;
2+
3+
namespace NArk.Abstractions.VirtualTxs;
4+
5+
/// <summary>
6+
/// Storage for virtual transactions and their association with VTXOs.
7+
/// </summary>
8+
public interface IVirtualTxStorage
9+
{
10+
/// <summary>
11+
/// Upsert virtual transaction records. If a tx already exists, updates non-null fields.
12+
/// </summary>
13+
Task UpsertVirtualTxsAsync(IReadOnlyList<VirtualTx> txs, CancellationToken cancellationToken = default);
14+
15+
/// <summary>
16+
/// Set the branch (ordered list of virtual txids) for a VTXO. Replaces any existing branch.
17+
/// </summary>
18+
Task SetBranchAsync(OutPoint vtxoOutpoint, IReadOnlyList<VtxoBranch> branch, CancellationToken cancellationToken = default);
19+
20+
/// <summary>
21+
/// Get the virtual txs in a VTXO's exit branch, ordered by position.
22+
/// </summary>
23+
Task<IReadOnlyList<VirtualTx>> GetBranchAsync(OutPoint vtxoOutpoint, CancellationToken cancellationToken = default);
24+
25+
/// <summary>
26+
/// Get a single virtual tx by txid.
27+
/// </summary>
28+
Task<VirtualTx?> GetVirtualTxAsync(string txid, CancellationToken cancellationToken = default);
29+
30+
/// <summary>
31+
/// Remove branch entries for a spent VTXO, then clean up orphaned VirtualTx rows.
32+
/// </summary>
33+
Task PruneForSpentVtxoAsync(OutPoint vtxoOutpoint, CancellationToken cancellationToken = default);
34+
35+
/// <summary>
36+
/// Check whether a VTXO has a stored branch.
37+
/// </summary>
38+
Task<bool> HasBranchAsync(OutPoint vtxoOutpoint, CancellationToken cancellationToken = default);
39+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace NArk.Abstractions.VirtualTxs;
2+
3+
/// <summary>
4+
/// A single virtual transaction in the VTXO tree.
5+
/// </summary>
6+
public record VirtualTx(string Txid, string? Hex, DateTimeOffset? ExpiresAt);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace NArk.Abstractions.VirtualTxs;
2+
3+
/// <summary>
4+
/// Controls how much virtual tx data is stored.
5+
/// </summary>
6+
public enum VirtualTxMode
7+
{
8+
/// <summary>
9+
/// Store only txids and branch structure. Tx hex is fetched on demand when needed for exit.
10+
/// </summary>
11+
Lite = 0,
12+
13+
/// <summary>
14+
/// Store txids AND raw tx hex immediately on VTXO receive. Ready for instant exit.
15+
/// </summary>
16+
Full = 1
17+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace NArk.Abstractions.VirtualTxs;
2+
3+
/// <summary>
4+
/// Links a VTXO to one virtual tx in its exit branch, with position ordering.
5+
/// Position 0 = closest to commitment tx (tree root), higher = closer to leaf.
6+
/// </summary>
7+
public record VtxoBranch(string VtxoTxid, uint VtxoVout, string VirtualTxid, int Position);
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
using System.Net.Http.Json;
2+
using System.Text;
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization;
5+
using Microsoft.Extensions.Logging;
6+
using NArk.Abstractions.Blockchain;
7+
using NBitcoin;
8+
9+
namespace NArk.Blockchain.Esplora;
10+
11+
/// <summary>
12+
/// Broadcasts transactions via Esplora REST API.
13+
/// Supports single tx broadcast and package relay.
14+
/// </summary>
15+
public class EsploraOnchainBroadcaster : IOnchainBroadcaster
16+
{
17+
private readonly HttpClient _httpClient;
18+
private readonly ILogger? _logger;
19+
20+
public EsploraOnchainBroadcaster(Uri baseUri, ILogger<EsploraOnchainBroadcaster>? logger = null)
21+
{
22+
_httpClient = new HttpClient { BaseAddress = baseUri };
23+
_logger = logger;
24+
}
25+
26+
public EsploraOnchainBroadcaster(HttpClient httpClient, ILogger<EsploraOnchainBroadcaster>? logger = null)
27+
{
28+
_httpClient = httpClient;
29+
_logger = logger;
30+
}
31+
32+
public async Task<bool> BroadcastAsync(Transaction tx, CancellationToken cancellationToken = default)
33+
{
34+
try
35+
{
36+
var content = new StringContent(tx.ToHex(), Encoding.UTF8, "text/plain");
37+
var response = await _httpClient.PostAsync("tx", content, cancellationToken);
38+
39+
if (!response.IsSuccessStatusCode)
40+
{
41+
var error = await response.Content.ReadAsStringAsync(cancellationToken);
42+
_logger?.LogWarning("Esplora broadcast failed for tx {Txid}: {Error}",
43+
tx.GetHash(), error);
44+
return false;
45+
}
46+
47+
return true;
48+
}
49+
catch (Exception ex)
50+
{
51+
_logger?.LogWarning(0, ex, "Failed to broadcast tx {Txid} via Esplora", tx.GetHash());
52+
return false;
53+
}
54+
}
55+
56+
public async Task<bool> BroadcastPackageAsync(
57+
Transaction parent, Transaction child, CancellationToken cancellationToken = default)
58+
{
59+
try
60+
{
61+
var package = new[] { parent.ToHex(), child.ToHex() };
62+
var response = await _httpClient.PostAsJsonAsync("txs/package", package, cancellationToken);
63+
64+
if (!response.IsSuccessStatusCode)
65+
{
66+
var error = await response.Content.ReadAsStringAsync(cancellationToken);
67+
_logger?.LogWarning("Esplora package broadcast failed: {Error}", error);
68+
return false;
69+
}
70+
71+
return true;
72+
}
73+
catch (Exception ex)
74+
{
75+
_logger?.LogWarning(0, ex, "Failed to broadcast package via Esplora");
76+
return false;
77+
}
78+
}
79+
80+
public async Task<TxStatus> GetTxStatusAsync(
81+
uint256 txid, CancellationToken cancellationToken = default)
82+
{
83+
try
84+
{
85+
var response = await _httpClient.GetAsync(
86+
$"tx/{txid}/status", cancellationToken);
87+
88+
if (!response.IsSuccessStatusCode)
89+
return new TxStatus(false, null, false);
90+
91+
var status = await response.Content.ReadFromJsonAsync<EsploraTxStatus>(
92+
cancellationToken: cancellationToken);
93+
94+
if (status is null)
95+
return new TxStatus(false, null, false);
96+
97+
return new TxStatus(
98+
status.Confirmed,
99+
status.Confirmed ? (uint?)status.BlockHeight : null,
100+
!status.Confirmed);
101+
}
102+
catch
103+
{
104+
return new TxStatus(false, null, false);
105+
}
106+
}
107+
108+
public async Task<FeeRate> EstimateFeeRateAsync(
109+
int confirmTarget = 6, CancellationToken cancellationToken = default)
110+
{
111+
try
112+
{
113+
var response = await _httpClient.GetAsync("fee-estimates", cancellationToken);
114+
response.EnsureSuccessStatusCode();
115+
116+
var estimates = await response.Content.ReadFromJsonAsync<Dictionary<string, double>>(
117+
cancellationToken: cancellationToken);
118+
119+
if (estimates is null)
120+
return new FeeRate(Money.Satoshis(2));
121+
122+
// Find the closest target
123+
var targetStr = confirmTarget.ToString();
124+
if (estimates.TryGetValue(targetStr, out var rate))
125+
return new FeeRate(Money.Satoshis((long)Math.Ceiling(rate)));
126+
127+
// Fallback to nearest available target
128+
var closest = estimates
129+
.Select(kvp => (Target: int.TryParse(kvp.Key, out var t) ? t : int.MaxValue, Rate: kvp.Value))
130+
.Where(x => x.Target != int.MaxValue)
131+
.OrderBy(x => Math.Abs(x.Target - confirmTarget))
132+
.FirstOrDefault();
133+
134+
return closest.Rate > 0
135+
? new FeeRate(Money.Satoshis((long)Math.Ceiling(closest.Rate)))
136+
: new FeeRate(Money.Satoshis(2));
137+
}
138+
catch (Exception ex)
139+
{
140+
_logger?.LogWarning(0, ex, "Failed to estimate fee rate via Esplora, using fallback");
141+
return new FeeRate(Money.Satoshis(2));
142+
}
143+
}
144+
145+
private class EsploraTxStatus
146+
{
147+
[JsonPropertyName("confirmed")]
148+
public bool Confirmed { get; set; }
149+
150+
[JsonPropertyName("block_height")]
151+
public long BlockHeight { get; set; }
152+
}
153+
}

0 commit comments

Comments
 (0)