Skip to content

MDK-980: Claim wire + verification in the LSP#25

Merged
amackillop merged 5 commits into
lsp-0.2.0from
austin_mdk-980_claim-verification
Jun 10, 2026
Merged

MDK-980: Claim wire + verification in the LSP#25
amackillop merged 5 commits into
lsp-0.2.0from
austin_mdk-980_claim-verification

Conversation

@amackillop

Copy link
Copy Markdown

Summary

Lets a registering node carry a signed grant for a non-standard FeePolicy.
The LSP verifies the grant locally (no network I/O), persists the granted
policy onto the peer's SCID record before the SCID is handed out, and honors
it at the skim site. Inert in production: with no issuer key configured every
peer resolves Flat(Standard) and is skimmed the same 2% as today.

Builds on MDK-979 (#24), which
landed the FeePolicy/FeeTier ADT, resolve_skim, and the policy TLV on
ScidWithPeer. That work deferred the per-peer lookup; this PR makes the grant
real and wires that lookup up.

Companion PR: the issuer side (MDK-982,
the open-money TS minter) is CumuloGlobal/open-money#468. It mints the fee_claim
this PR verifies and pins the same in-tree test vector, so the two are validated
against each other.

What changed

Five atomic commits, just check green after each.

  • Claim ADT + verifier (claim.rs, new). ClaimPayload { scheme, node_id, policy } and SignedFeeClaim { payload, sig }, both TLV. The signature is
    BIP340 over SHA256(ClaimPayload.encode()); the outer container keeps the
    payload as opaque bytes so the verifier hashes exactly what was signed and
    never re-derives the TLV layout. verify_claim takes a slice of issuer keys
    (empty rejects everything), checks the scheme, the signature against any one
    key, and the node_id binding.
  • fee_claim on RegisterNodeRequest (msgs.rs, client.rs). Optional
    hex string, skip_serializing_if = "Option::is_none". An old client sending
    {} still decodes to None, and a node with no claim emits {}, so the wire
    is unchanged when no claim is set.
  • Policy retention in the SCID store (scid_store.rs). A policy_by_peer
    map kept in lockstep with the existing peer<->scid maps: built on load,
    upserted on insert, dropped on remove. get_policy reads it back.
  • Issuer keys + verify/persist in the handler (service.rs).
    issuer_pubkeys: Vec<XOnlyPublicKey> on LSPS4ServiceConfig, a long-lived
    verify context on the handler, and register_node resolving any presented
    claim into the SCID record before the response is enqueued.
  • Honor the policy at the skim site (service.rs).
    calculate_htlc_actions_for_peer reads the peer's policy from the store
    instead of the literal Flat(Standard).

Decisions

  • Never downgrade, but only against absence. A valid claim is authoritative
    and upserts the policy in either direction. An absent or unverifiable claim
    returns None and leaves a live record untouched, so a transient miss or a
    malformed claim on a later re-registration can't silently restore the 2% skim
    on a node that was granted zero-fee. This deviates from a literal "else
    Standard" on purpose.
  • Issuer keys are a Vec, not a single Option. Empty disables the feature.
    A non-empty set accepts a signature from any one key, which covers a no-key-id
    claim today and key rotation later without a breaking config change. Per-key
    policy scope (a promo key that must not grant permanent zero-fee) is a known
    gap: it slots in additively as a keyed &[(key, scope)] plus a payload key-id.
  • The wire format is the cross-repo contract. claim.rs ships an in-tree
    test vector (fixed issuer secret, fixed node secret, a ZeroFee claim pinned
    to its hex and issuer x-only key). The TS minter (open-money#468) already
    reproduces it byte-for-byte, and
    MDK-981 (ldk-node) will too;
    drift fails the test instead of diverging silently.
  • TLV, not fixed concatenation. New policy arms or an issuer key-id are new
    tags, additive, no re-issue.
  • The verifier borrows the caller's secp context rather than allocating per
    call; the service owns one long-lived verify context.

Behaviour preservation

Empty issuer_pubkeys short-circuits before any crypto, so every peer resolves
Flat(Standard) and the skim is byte-for-byte the historical 2%. The on-disk
ScidWithPeer layout is unchanged (the policy TLV default predates this work),
so no migration. A node only populates fee_claim once
MDK-981 and
MDK-982 land, so even with the
code merged nothing presents a claim until then.

Tests

  • claim.rs: payload round-trip, valid claim yields the granted policy, empty
    issuer set rejects, wrong key rejects, forged signature rejects, node_id
    mismatch, unknown scheme, malformed hex/TLV, multi-issuer second key verifies,
    a Custom policy survives verify, and the in-tree test vector.
  • msgs.rs: round-trip with a claim, field omitted when None, legacy {}
    decodes to None.
  • scid_store.rs: insert-with-policy then get_policy, load rebuilds the map,
    default record resolves to Standard, plus the existing serialization tests.

The handler glue (resolve + persist) and the skim read are not unit-tested: the
pure pieces they compose are, and exercising the handler needs the full node
harness which is saved for future integration test work.

Follow-ups

  • MDK-981 (ldk-node): plumb
    a fee_claim value into register_node (client role) and surface
    issuer_pubkeys config (LSP role).
  • Per-key issuer scope: deferred until a second, less-trusted issuer exists.
  • lsps4_integration_tests.rs is untracked and fully commented out; whoever
    revives that harness adds issuer_pubkeys: Vec::new() to its config literal.

MDK-980 lets a registering node carry a signed grant for a
non-standard FeePolicy that the LSP verifies locally before honouring.
This is just the pure core: the claim ADT and the verifier. Nothing
calls it yet, so behaviour is unchanged.

A claim is a versioned TLV pair. ClaimPayload binds {scheme, node_id,
policy}; SignedFeeClaim wraps the encoded payload as an opaque byte
string plus a detached BIP340 signature over SHA256 of those bytes.
Keeping the payload opaque means the verifier hashes exactly what was
signed and never reproduces the payload's TLV layout to check the
signature. TLV rather than a fixed concatenation keeps later schemes
(more policy arms, an issuer key-id) additive: a new tag, not a re-issue.

verify_claim takes a slice of issuer keys, not a single Option. An empty
slice rejects every claim, which is how the feature stays inert until a
key is configured. A non-empty slice accepts a signature from any one of
the keys. That covers a no-key-id claim today and key rotation later,
and per-key scoping can slot in without a breaking config change.

The verifier borrows a caller-supplied secp context rather than building
its own, so the verifier stays allocation free and context lifetime is
the caller's call. The handler wiring that follows will own a long-lived
verify context on the service and hand it in. The scheme byte is read
and matched before the signature check because it selects which
verification rules apply, so an unknown scheme is rejected before any
crypto runs.

The wire format is the contract MDK-981 (ldk-node) and MDK-982 (the TS
minter) must reproduce byte-for-byte, so an in-tree test vector pins a
known issuer key and claim hex. Drift in the TLV layout or the signing
input fails that test instead of silently diverging across repos. The
signing helper is test-only; the verifier does no I/O.
Adds an optional fee_claim string to RegisterNodeRequest so a node can
present a signed grant when it registers. The field is hex of the
SignedFeeClaim bytes from the previous commit. Nothing reads it yet; the
service-side verify and persist land later.

serde(default, skip_serializing_if = "Option::is_none") keeps the wire
backward compatible both ways. An old client that sends {} still decodes
to fee_claim: None through the existing params.unwrap_or(json!({})) path,
and a node with no claim emits {} rather than an explicit null, so the
request bytes match today exactly when no claim is set.

The client constructs the request with fee_claim: None; only a node that
has been granted a claim populates it, which is wired up in a later
change.
The persisted ScidWithPeer already carries a policy field, but the store
only built peer<->scid lookups, so the granted policy was written to disk
and then lost in memory. This adds a policy_by_peer map kept in lockstep
with the existing two: populated on load from the decoded records, upserted
on insert, dropped on remove. A get_policy accessor reads it back.

ScidWithPeer::new now takes the policy explicitly rather than defaulting it,
so there is one way to build the record and the policy is always named at
the construction site. add_intercepted_scid passes Flat(Standard), the path
a peer with no claim takes; the register handler will pass a verified grant.
The on-disk default is unchanged: it comes from the TLV default_value, not
the constructor. get_policy warns as dead code until the handler reads it.

Keeping policy in its own map rather than handing out whole ScidWithPeer
records keeps the skim site's read to a single lookup by node id, which is
all it needs.
Wires the claim verifier into register_node. The service now holds a set of
trusted issuer keys and a long-lived verification context; when a peer
registers, any fee_claim it presented is verified against those keys and the
granted policy is persisted onto the peer's SCID record before the response
is enqueued, so the policy is in place by the time the SCID is handed out.

The feature is inert by default. An empty issuer_pubkeys set short-circuits
before any crypto and resolves every peer to the standard policy, byte for
byte identical to today. Population of the key set is the operator's job
downstream; nothing in-tree sets it.

A grant is only ever upserted, never downgraded. An absent or unverifiable
claim returns None and leaves an existing record untouched, so a transient
miss or a malformed claim can't wipe a live grant; a brand-new record falls
back to standard. This deviates from a literal "else standard" on purpose:
once a node has been granted zero-fee, a dropped claim on a later
re-registration must not silently restore the 2% skim.

The verifier borrows the service's context rather than allocating per call,
and resolve_claim_policy logs and swallows verification failures rather than
failing the registration: a bad claim should cost the node its discount, not
its channel.

add_intercepted_scid is removed. It only built a standard-policy record,
which the new persist path now does directly with the resolved policy, so
the old helper had no remaining caller.
The forwarding skim now reads the peer's granted policy from the SCID store
instead of hard-coding Flat(Standard). A peer with no grant resolves to
Standard, so the skim is byte-for-byte the historical 2% for everyone the
issuer set has not waived. This is the read side that makes the persisted
grant actually take effect; before it, a verified ZeroFee was stored and
then ignored.

The lookup is hoisted out of the per-HTLC loop because the policy is keyed
by peer, not by HTLC, so it is one store read per batch rather than one per
forward.

A zero skim is now two distinct cases, so the log is split. ZeroFee is the
expected path and logs at info. Any other tier skimming nothing means the
fee would have consumed the whole HTLC, which keeps the error-level log:
a forward of the full amount that we expected to take a cut on. Previously
ZeroFee was unreachable, so the single error log was correct; now that a
grant can legitimately waive the fee, the error would be noise.
@amackillop amackillop merged commit 521f908 into lsp-0.2.0 Jun 10, 2026
10 of 43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant