Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5a984e4
swapkit: Share Bitcoin script leaf builders
hieblmi Nov 3, 2026
749c8aa
swapkit: Define asset deposit, reservation and HTLC contracts
hieblmi Nov 3, 2026
0d63469
tapsdk: Use tap-sdk v0.1.1 and require Taproot Assets v0.8.8
hieblmi Nov 3, 2026
5916d94
tapsdk: Validate asset proofs and current-chain inputs
hieblmi Nov 11, 2026
d464aa5
tapsdk: Approve transfer plans against agreed terms
hieblmi Nov 3, 2026
79e4171
tapsdk: Bind signing to the approved package
hieblmi Nov 3, 2026
9e4c1e6
tapsdk: Verify presigned descendants through confirmation
hieblmi Nov 3, 2026
7ce2098
tapsdk: Recover claims and refunds from observed spends
hieblmi Nov 11, 2026
d620bd2
tapsdk: Harden recovery, fallback and witness declaration
hieblmi Nov 11, 2026
c351bbc
swapserverrpc: Define the asset reservation purchase API
hieblmi Sep 10, 2026
1f96ad9
assets/reservation: Define quoted amounts and lifetime limits
hieblmi Sep 10, 2026
78f61d3
loopdb: Add reservation terms and state history tables
hieblmi Sep 10, 2026
644b4a4
loopdb: Add fields for reservation purchase recovery
hieblmi Sep 10, 2026
c0b2a58
assets/reservation: Save terms and state atomically
hieblmi Sep 10, 2026
74d4b1a
assets/reservation: Derive deadlines from funding confirmation
hieblmi Sep 10, 2026
adf0cba
looprpc: Separate reservation quotes from payment approval
hieblmi Sep 10, 2026
0958610
assets/reservation: Preserve purchase facts across restarts
hieblmi Sep 10, 2026
2cb37be
assets/reservation: Add the client purchase state machine
hieblmi Sep 10, 2026
80868f3
assets/reservation: Add a manager with one worker per purchase
hieblmi Sep 10, 2026
c103187
assets/reservation: Verify deposits and track them through LND
hieblmi Sep 10, 2026
626eb51
assets/payment: Check asset quotes and signed invoices
hieblmi Sep 10, 2026
dac86aa
assets/reservation: Send hold probes and approved BTC prepays
hieblmi Sep 10, 2026
b5cb9c2
assets/reservation: Wire purchases into loopd and local RPC
hieblmi Sep 10, 2026
52470f6
cmd/loop: Add asset reservation purchase and inspection commands
hieblmi Sep 10, 2026
08be4be
docs: Describe reservation hold probes and recorded test runs
hieblmi Sep 10, 2026
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
8 changes: 8 additions & 0 deletions .github/actions/lit-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ runs:
go mod edit -replace=github.com/lightninglabs/loop=../
go mod edit -replace=github.com/lightninglabs/loop/looprpc=../looprpc
go mod edit -replace=github.com/lightninglabs/loop/swapserverrpc=../swapserverrpc
# Module replacements are not inherited by the combined daemon.
# Keep its proof decoder and other source overrides aligned with Loop.
while IFS= read -r replacement; do
go mod edit "$replacement"
done < <(go mod edit -json ../go.mod | jq -r '
.Replace[] | select(.New.Version != null) |
"-replace=\(.Old.Path)\(if .Old.Version then "@" + .Old.Version else "" end)=\(.New.Path)@\(.New.Version)"
')
go mod tidy
shell: bash

Expand Down
95 changes: 95 additions & 0 deletions assets/payment/invoice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package payment

import (
"bytes"
"fmt"
"math"
"time"

"github.com/btcsuite/btcd/chaincfg"
"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/zpay32"
)

// InvoiceTerms binds BOLT11's Bitcoin amount to an independently agreed asset
// contract. PaymentAddress, when known, binds the saved invoice.
type InvoiceTerms struct {
AssetID [32]byte
Amount uint64
Hash [32]byte
Payee [33]byte
PaymentAddress [32]byte
MinFinalCLTV uint64
MinLifetime time.Duration
}

// ValidateInvoice checks the signed invoice, not just the creation request.
// Invoice creation must use the supplied RFQ without negotiating another one.
// This function is an admission check; use raw receipt validation to reconcile
// a payment that already settled after its quote or invoice expired.
func ValidateInvoice(encoded string, quote *rfqrpc.PeerAcceptedBuyQuote,
terms InvoiceTerms, params *chaincfg.Params, now time.Time) (
*zpay32.Invoice, error) {

if params == nil || terms.Hash == ([32]byte{}) ||
terms.MinFinalCLTV == 0 || terms.MinFinalCLTV > math.MaxUint16 ||
terms.MinLifetime <= 0 {

return nil, fmt.Errorf("invalid invoice contract")
}
invoice, err := zpay32.Decode(encoded, params)
if err != nil {
return nil, err
}
expires := invoice.Timestamp.Add(invoice.Expiry())
if !expires.After(now.Add(terms.MinLifetime)) ||
invoice.MinFinalCLTVExpiry() < terms.MinFinalCLTV ||
invoice.MinFinalCLTVExpiry() > math.MaxUint16 {

return nil, fmt.Errorf("unsafe invoice lifetime")
}
expected, err := ReceivingAmount(quote, terms.AssetID, terms.Amount,
expires)
if err != nil {
return nil, err
}
if invoice.PaymentHash == nil || *invoice.PaymentHash != terms.Hash ||
invoice.Destination == nil || !bytes.Equal(
invoice.Destination.SerializeCompressed(), terms.Payee[:],
) || invoice.MilliSat == nil ||
uint64(*invoice.MilliSat) != expected {

return nil, fmt.Errorf("invoice does not match asset contract")
}
if invoice.PaymentAddr.IsNone() || invoice.Features == nil ||
!invoice.Features.HasFeature(lnwire.PaymentAddrOptional) ||
!invoice.Features.HasFeature(lnwire.TLVOnionPayloadOptional) ||
invoice.Features.HasFeature(lnwire.AMPOptional) {

return nil, fmt.Errorf("invoice requires non-AMP payment secret")
}
secret := invoice.PaymentAddr.UnsafeFromSome()
if secret == ([32]byte{}) || (terms.PaymentAddress != ([32]byte{}) &&
secret != terms.PaymentAddress) {

return nil, fmt.Errorf("invoice payment secret mismatch")
}
if len(invoice.RouteHints) != 1 || len(invoice.RouteHints[0]) != 1 ||
len(invoice.BlindedPaymentPaths) != 0 {

return nil, fmt.Errorf("invoice must use one receiving edge hint")
}
peer, err := parsePeer(quote.Peer)
if err != nil {
return nil, err
}
hint := invoice.RouteHints[0][0]
if hint.NodeID == nil || !bytes.Equal(
hint.NodeID.SerializeCompressed(), peer,
) || hint.ChannelID != quote.Scid || hint.CLTVExpiryDelta == 0 {

return nil, fmt.Errorf("invoice receiving quote mismatch")
}
return invoice, nil
}
161 changes: 161 additions & 0 deletions assets/payment/quote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Package payment checks asset quotes and signed Lightning invoices.
// Bitcoin routing amounts are millisatoshis; assets are integer base units.
package payment

import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"math"
"math/big"
"time"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightninglabs/taproot-assets/rfqmath"
"github.com/lightninglabs/taproot-assets/rfqmsg"
"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
"github.com/lightningnetwork/lnd/lnwire"
)

var (
// ErrQuoteUnavailable means no usable quote exists. It does not prove
// that an outstanding payment failed or that an invoice was canceled.
ErrQuoteUnavailable = errors.New("asset payment quote unavailable")
)

// Rate decodes canonical positive decimal coefficients with bounded scale.
func Rate(value *rfqrpc.FixedPoint) (rfqmath.BigIntFixedPoint, error) {
if value == nil || value.Scale > 18 || len(value.Coefficient) > 78 {
return rfqmath.BigIntFixedPoint{}, fmt.Errorf("invalid asset rate")
}
n, ok := new(big.Int).SetString(value.Coefficient, 10)
if !ok || n.Sign() <= 0 || n.String() != value.Coefficient {
return rfqmath.BigIntFixedPoint{}, fmt.Errorf("invalid asset rate")
}
return rfqmath.BigIntFixedPoint{
Coefficient: rfqmath.NewBigInt(n),
Scale: uint8(value.Scale),
}, nil
}

// Units uses the deployed edge's millisatoshi-to-unit rounding.
func Units(msat uint64, rate rfqmath.BigIntFixedPoint) (uint64, error) {
units := rfqmath.MilliSatoshiToUnits(lnwire.MilliSatoshi(msat), rate)
result, ok := units.ScaleTo(0).ToUint64Checked()
if !ok {
return 0, fmt.Errorf("asset amount overflow")
}
return result, nil
}

// ExactMSat returns the least millisatoshi amount that delivers exactly the
// requested integer assets in one HTLC. Rounding an asset amount DOWN to msat
// and back can lose one asset unit. Round UP, then verify using the deployed
// conversion instead of granting a tolerance. Some rates cannot represent a
// given asset amount at millisatoshi resolution; reject those prices.
func ExactMSat(amount uint64, value *rfqrpc.FixedPoint) (uint64, error) {
rate, err := Rate(value)
if err != nil {
return 0, err
}
if amount == 0 || amount > math.MaxInt64 {
return 0, fmt.Errorf("invalid asset amount")
}
scale := new(big.Int).Exp(big.NewInt(10),
new(big.Int).SetUint64(uint64(value.Scale)), nil)
numerator := new(big.Int).Mul(new(big.Int).SetUint64(amount), scale)
numerator.Mul(numerator, big.NewInt(100_000_000_000))
coefficient, _ := new(big.Int).SetString(value.Coefficient, 10)
quotient, remainder := new(big.Int), new(big.Int)
quotient.QuoRem(numerator, coefficient, remainder)
if remainder.Sign() != 0 {
quotient.Add(quotient, big.NewInt(1))
}
if !quotient.IsInt64() || quotient.Sign() <= 0 {
return 0, fmt.Errorf("converted invoice amount overflow")
}
msat := quotient.Uint64()
actual, err := Units(msat, rate)
if err != nil || actual != amount {
return 0, fmt.Errorf("rate cannot deliver exact asset amount")
}
return msat, nil
}

func quoteIdentity(id, peer []byte, spec *rfqrpc.AssetSpec,
expected [32]byte) error {

if expected == ([32]byte{}) || len(id) != 32 ||
bytes.Equal(id, make([]byte, 32)) || len(peer) != 33 ||
spec == nil || !bytes.Equal(spec.Id, expected[:]) ||
len(spec.GroupPubKey) != 0 {

return fmt.Errorf("quote identity does not match asset contract")
}
key, err := btcec.ParsePubKey(peer)
if err != nil || !bytes.Equal(key.SerializeCompressed(), peer) {
return fmt.Errorf("noncanonical quote peer")
}
return nil
}

func parsePeer(value string) ([]byte, error) {
peer, err := hex.DecodeString(value)
if err != nil || hex.EncodeToString(peer) != value {
return nil, fmt.Errorf("invalid quote peer")
}
return peer, nil
}

// ReceivingAmount validates the receiving RFQ and returns the exact invoice
// amount. A quote's capacity is not an instruction to transfer that capacity.
// The edge floors its capacity's msat bound: a quote for A units may not permit
// the ceil-rounded payment for A. The caller may obtain capacity for A+1, but
// must keep the invoice, actual receipt and credit at A. Never negotiate a new
// rate implicitly while creating an invoice.
func ReceivingAmount(quote *rfqrpc.PeerAcceptedBuyQuote,
assetID [32]byte, amount uint64, validThrough time.Time) (uint64, error) {

if quote == nil {
return 0, ErrQuoteUnavailable
}
peer, err := parsePeer(quote.Peer)
if err != nil {
return 0, err
}
if err := quoteIdentity(quote.Id, peer, quote.AssetSpec, assetID); err != nil {
return 0, err
}
var id rfqmsg.ID
copy(id[:], quote.Id)
if quote.Scid != uint64(id.Scid()) || quote.Expiry > math.MaxInt64 ||
!time.Unix(int64(quote.Expiry), 0).After(validThrough) {

return 0, ErrQuoteUnavailable
}
capacity := quote.AssetMaxAmount
if quote.AcceptedMaxAmount != 0 {
capacity = min(capacity, quote.AcceptedMaxAmount)
}
if amount < quote.MinTransportableUnits || capacity < amount ||
capacity > math.MaxInt64 {

return 0, fmt.Errorf("receiving quote lacks required asset capacity")
}
msat, err := ExactMSat(amount, quote.AskAssetRate)
if err != nil {
return 0, err
}
rate, err := Rate(quote.AskAssetRate)
if err != nil {
return 0, err
}
maximum, err := rfqmath.UnitsToMilliSatoshi(
rfqmath.NewBigIntFixedPoint(capacity, 0), rate,
)
if err != nil || uint64(maximum) < msat {
return 0, fmt.Errorf("receiving quote lacks rounding capacity")
}
return msat, nil
}
Loading
Loading