feat(app): flag transactions above the newest checkpoint as not anchored

Blocks mined after the newest hardcoded checkpoint cannot be chained to it,
so only the Merkle proof against the server's header backs them. The
history list and the transaction detail now show a 'not anchored' label
for those transactions. Informational only: spending is not blocked.
See ADR 0041.
This commit is contained in:
davide committed 2026-11-03 15:11:06 +01:00
1 parent 24368a07b5
commit 305f22ed0e
13 files changed
+119 -7

No files matched your search

+5
View File
@@ -14,6 +14,11 @@ release and covers the full history from the initial commit.
### Added
- History rows and the transaction detail now flag a confirmed transaction in a block above the
newest hardcoded checkpoint as **not anchored** (orange label with an explanatory tooltip).
Such a block cannot be chained to a checkpoint (ADR 0040), so only the Merkle proof against the
server's header backs it; the flag makes the reduced guarantee visible. Not shown on
testnet/regtest, which have no checkpoints. See ADR 0041.
- Fee presets from the server: after connecting, the Send tab offers Fast, Normal and Economy
rates from `blockchain.estimatefee` (2, 6 and 24 blocks) and shows them with the relay fee;
Normal is preselected unless the user already typed a rate. Estimates are converted from
+1 -1
View File
@@ -21,7 +21,7 @@ It does **not** protect against:
This wallet is an SPV client, not a full node. It validates:
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) **up to the nearest hardcoded checkpoint at or above its height**, and the header at that checkpoint height must hash to the checkpoint exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`, ADR 0040). The direction matters: with PoW skipped, a chain built *from* a checkpoint *up to* a header proves nothing, because anyone can fabricate such a chain; a chain ending in a known hash cannot be forged without inverting SHA-256. Mainnet checkpoints run every 20,000 blocks from 20,000 (so even the oldest blocks are covered) and are extended at each release. **Known limit:** a transaction in a block *above the highest checkpoint* has no checkpoint to anchor to, so only its Merkle proof against the header the server supplied is checked there; the window is the blocks mined since the last release. Testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) **up to the nearest hardcoded checkpoint at or above its height**, and the header at that checkpoint height must hash to the checkpoint exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`, ADR 0040). The direction matters: with PoW skipped, a chain built *from* a checkpoint *up to* a header proves nothing, because anyone can fabricate such a chain; a chain ending in a known hash cannot be forged without inverting SHA-256. Mainnet checkpoints run every 20,000 blocks from 20,000 (so even the oldest blocks are covered) and are extended at each release. **Known limit:** a transaction in a block *above the highest checkpoint* has no checkpoint to anchor to, so only its Merkle proof against the header the server supplied is checked there; the window is the blocks mined since the last release. The wallet flags such transactions as **not anchored** in the history list and in the transaction detail (ADR 0041), so the reduced guarantee is visible instead of silent. Testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
It does **not** validate:
+5 -1
View File
@@ -582,7 +582,10 @@ On mainnet, each block header used for a proof is also chained up (via the previ
hashes) to the nearest built-in checkpoint above it, which must match exactly, so a server
cannot substitute a fabricated header for a real one. The blocks mined after the newest
checkpoint (shipped with each release) cannot be anchored this way: for those only the Merkle
proof is checked, which is why updating the wallet regularly matters. Verified data is cached in the wallet file, so later syncs only fetch what
proof is checked, which is why updating the wallet regularly matters. Such transactions carry an
orange **"not anchored"** label in the History tab and in the transaction detail (hover it for
the explanation); it disappears once you update to a release whose newest checkpoint covers
that block. The label is informational: the transaction is still Merkle-verified. Verified data is cached in the wallet file, so later syncs only fetch what
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
unverified data; switch servers.
@@ -858,6 +861,7 @@ Reset SSL certificates* — see
| *Wrong password.* | The password does not decrypt the file (or the file was tampered with — the two are indistinguishable by design). | Retry; check keyboard layout/caps lock. If truly lost, restore from seed ([15.2](#152-restoring-step-by-step)). |
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
| History shows an orange *"not anchored"* next to a transaction | The block is newer than the newest checkpoint built into this version, so its header cannot be chained to a hardcoded hash; only the Merkle proof backs it. | Normal for the most recent blocks. Update the wallet to a newer release to anchor them; for large amounts, wait until it is no longer flagged. |
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain up to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
| *The server host:port is not on the Palladium chain (different genesis or checkpoint)…* | The server answered, but serves another chain (or a fork) than the wallet expects. Nothing was synced from it. | Pick another server; the wallet moves on to the next known one on its own. See [12.3](#123-what-synchronization-actually-does). |
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
@@ -0,0 +1,28 @@
# 0041. Flag transactions above the newest checkpoint as not anchored
- **Status:** Accepted
- **Area:** SPV
- **Recorded:** 2026-11-03
## Context
ADR 0040 anchors every header used for a Merkle proof up to a hardcoded checkpoint at or above it. Blocks above the newest checkpoint (those mined since the last release) have nothing to anchor to, so for them only the Merkle proof against the header the server supplied is checked. This is a known limit; the user could not see which transactions it applied to.
## Decision
- `ChainProfile.IsAboveNewestCheckpoint(height)` is true for a confirmed height above the highest checkpoint of a profile that has checkpoints. It is false for unconfirmed transactions and for profiles without checkpoints (testnet/regtest), where nothing is anchored by design and the notion does not apply.
- `HistoryRow` carries `Unanchored`; the History list (desktop and mobile layouts) shows an orange "not anchored" label with a tooltip explaining it, and the transaction detail appends the same text to its status line.
- The flag is informational: it does not block spending or change the Merkle verification. Reducing the window itself is done by shipping a recent checkpoint with every release; a second-server cross-check or a bounded proof-of-work check remain open options (see the backlog).
## Consequences
- The reduced guarantee is visible per transaction instead of silent, and disappears after an update whose checkpoint covers the block.
- Every wallet user sees the label on the most recent transactions between releases; the tooltip and the user guide explain it. It can alarm users unnecessarily, which is the price of honesty here.
## References
- `src/Core/Chain/ChainProfile.cs`
- `src/App/ViewModels/MainWindowViewModel.Receive.cs`
- `src/App/Views/MainView.axaml`
- `tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs`
- Related: 0040
+1
View File
@@ -44,6 +44,7 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf
| 0038 | [CLI password sources and explicit plaintext storage](0038-cli-password-sources-and-explicit-plaintext.md) | Distribution | Accepted |
| 0039 | [Server fee estimates, relay floor and a high-fee warning](0039-server-fee-estimates-and-relay-floor.md) | Wallet | Accepted |
| 0040 | [Anchor headers up to a checkpoint above them](0040-anchor-headers-up-to-a-checkpoint.md) | SPV | Accepted |
| 0041 | [Flag transactions above the newest checkpoint as not anchored](0041-flag-transactions-above-the-newest-checkpoint.md) | SPV | Accepted |
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
If one misstates the original reasoning, fix it in a pull request.
+2
View File
@@ -436,6 +436,8 @@ public sealed class Loc
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar", "尚不可花费"],
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung", "成熟中"],
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft", "正在进行 SPV 验证"],
["history.unanchored"] = ["non ancorata", "not anchored", "sin anclar", "non ancrée", "não ancorada", "nicht verankert", "未锚定"],
["history.unanchored.tip"] = ["Blocco successivo all'ultimo checkpoint della versione: la sua intestazione e la prova Merkle arrivano dal server e non si collegano a un checkpoint. Aggiorna il wallet per ancorarla.", "Block after the newest checkpoint of this version: its header and Merkle proof come from the server and do not chain to a checkpoint. Update the wallet to anchor it.", "Bloque posterior al último punto de control de esta versión: su cabecera y la prueba Merkle vienen del servidor y no se enlazan a un punto de control. Actualiza la cartera para anclarlo.", "Bloc postérieur au dernier point de contrôle de cette version : son en-tête et la preuve Merkle viennent du serveur et ne se rattachent à aucun point de contrôle. Mettez le portefeuille à jour pour l'ancrer.", "Bloco posterior ao último checkpoint desta versão: o cabeçalho e a prova Merkle vêm do servidor e não se ligam a um checkpoint. Atualize a carteira para ancorá-lo.", "Block nach dem neuesten Checkpoint dieser Version: Header und Merkle-Beweis stammen vom Server und sind an keinen Checkpoint gekettet. Aktualisieren Sie die Wallet, um ihn zu verankern.", "此版本最新检查点之后的区块:其区块头和 Merkle 证明来自服务器,未链接到任何检查点。请更新钱包以完成锚定。"],
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…", "验证中…"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert.", "设置已保存。"],
["msg.certreset"] = [
@@ -240,6 +240,7 @@ public partial class MainWindowViewModel
var transactions = _lastTransactions;
var loc = _loc;
var unit = _config.Unit;
var unanchored = Profile.IsAboveNewestCheckpoint(height);
try
{
@@ -247,7 +248,7 @@ public partial class MainWindowViewModel
{
var details = await TransactionInspector.FetchAsync(
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
return new TransactionDetailsViewModel(details, loc, unit);
return new TransactionDetailsViewModel(details, loc, unit, unanchored);
}, ct);
}
catch (OperationCanceledException)
@@ -310,7 +311,8 @@ public partial class MainWindowViewModel
tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid,
tx.Verified));
tx.Verified,
Profile.IsAboveNewestCheckpoint(tx.Height)));
Addresses.Clear();
foreach (var a in cache.Addresses)
+2 -1
View File
@@ -17,7 +17,8 @@ using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Transaction history row for the view.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true,
bool Unanchored = false);
/// <summary>
/// Address view row: public data and derivation path only. The private key is never held here;
@@ -21,14 +21,14 @@ public sealed class TransactionDetailsViewModel
public Loc Loc { get; }
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit, bool unanchored = false)
{
Loc = loc;
_unit = unit;
Txid = d.Txid;
IsCoinbase = d.IsCoinbase;
StatusText = BuildStatus(d, loc);
StatusText = BuildStatus(d, loc) + (unanchored ? $" — {loc["history.unanchored"]}" : "");
DateText = d.BlockTime is { } t
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: loc["tx.mempool"];
+7
View File
@@ -402,6 +402,10 @@
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
IsVisible="{Binding !Verified}"/>
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored]}"
Foreground="#FB923C" FontSize="11" Margin="6,0,0,0"
ToolTip.Tip="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored.tip]}"
IsVisible="{Binding Unanchored}"/>
</Grid>
<!-- Mobile: vertical card -->
<StackPanel Spacing="2"
@@ -414,6 +418,9 @@
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11"
IsVisible="{Binding !Verified}"/>
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored]}"
Foreground="#FB923C" FontSize="11"
IsVisible="{Binding Unanchored}"/>
</StackPanel>
</Panel>
</DataTemplate>
+9
View File
@@ -140,4 +140,13 @@ public sealed record ChainProfile
/// <summary>Hardcoded checkpoints, updated at every release (§7.3).</summary>
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
/// <summary>
/// True for a block above the newest checkpoint of a profile that has checkpoints: its header
/// cannot be anchored (ADR 0040), so only the server-supplied header and the Merkle proof back
/// it. False for confirmed history the anchoring covers, and for profiles without any
/// checkpoint (testnet/regtest), where nothing is anchored by design and the notion does not apply.
/// </summary>
public bool IsAboveNewestCheckpoint(int height) =>
height > 0 && Checkpoints.Count > 0 && height > Checkpoints.Max(c => c.Height);
}
@@ -396,6 +396,40 @@ public sealed class MainWindowViewModelTests : VmTestBase
Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded
});
[Fact]
public Task HistoryRows_AboveTheNewestCheckpoint_AreFlaggedAsUnanchored() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 2_500_000); // confirmed at height 100
// The fake serves the same header for every height: use its hash as a checkpoint at 50.
string headerHash;
await using (var c = await ElectrumClient.ConnectAsync(_server.Host, _server.Port, useSsl: false))
headerHash = BlockHeaderInfo.Parse(await c.GetBlockHeaderAsync(50)).Hash.ToString();
MainWindowViewModel.ProfileOverride = ChainProfiles.Mainnet with
{
BootstrapServers = [],
Checkpoints = [new Checkpoint(50, headerHash, 0x1d00ffff)],
};
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "unanch");
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1);
Assert.True(vm.History[0].Verified); // the Merkle proof checks out
Assert.True(vm.History[0].Unanchored); // but height 100 is above the checkpoint at 50
});
[Fact]
public Task HistoryRows_CoveredByACheckpoint_AreNotFlagged() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 2_500_000);
var vm = new MainWindowViewModel(); // default test profile: no checkpoints
RestoreFlow(vm, Mnemonic, null, "anch");
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1);
Assert.False(vm.History[0].Unanchored);
});
[Fact]
public Task FeePresets_ComeFromTheServer_AndTheNormalEstimateIsPreselected() => UiSession.Run(async () =>
{
@@ -116,4 +116,23 @@ public class ChainProfileTests
data[3] = (byte)header;
return Encoders.Base58Check.EncodeData(data);
}
[Theory]
[InlineData(0, false)] // unconfirmed
[InlineData(1, false)]
[InlineData(100, false)] // at the newest checkpoint: still anchored
[InlineData(101, true)]
[InlineData(5_000_000, true)]
public void IsAboveNewestCheckpoint_flags_only_heights_the_anchoring_cannot_cover(int height, bool expected)
{
var profile = ChainProfiles.Mainnet with
{
Checkpoints = [new Checkpoint(50, "00", 0), new Checkpoint(100, "00", 0)],
};
Assert.Equal(expected, profile.IsAboveNewestCheckpoint(height));
}
[Fact]
public void A_profile_without_checkpoints_never_reports_an_unanchored_height() =>
Assert.False((ChainProfiles.Mainnet with { Checkpoints = [] }).IsAboveNewestCheckpoint(1_000_000));
}