Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ cmd/tapd/tapd
/itest/custom_channels/*.log.*

/docs/examples/basic-price-oracle/basic-price-oracle
/docs/examples/basic-portfolio-pilot/basic-portfolio-pilot
basic-portfolio-pilot

# Test binary, built with `go test -c`
*.test
Expand Down
14 changes: 12 additions & 2 deletions docs/examples/basic-portfolio-pilot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,19 @@ func (p *RpcPortfolioPilotServer) ResolveRequest(_ context.Context,
var hint *portfoliopilotrpc.AssetRate
switch r := req.GetRequest().(type) {
case *portfoliopilotrpc.ResolveRequestRequest_BuyRequest:
hint = r.BuyRequest.GetAssetRateHint()
br := r.BuyRequest
hint = br.GetAssetRateHint()
log.Printf("ResolveRequest buy: max=%d min=%d "+
"rate_limit=%v", br.GetAssetMaxAmount(),
br.GetAssetMinAmount(),
br.GetAssetRateLimit())
case *portfoliopilotrpc.ResolveRequestRequest_SellRequest:
hint = r.SellRequest.GetAssetRateHint()
sr := r.SellRequest
hint = sr.GetAssetRateHint()
log.Printf("ResolveRequest sell: max=%d min=%d "+
"rate_limit=%v", sr.GetPaymentMaxAmount(),
sr.GetPaymentMinAmount(),
sr.GetAssetRateLimit())
default:
return nil, fmt.Errorf("unknown request type: %T", r)
}
Expand Down
19 changes: 19 additions & 0 deletions docs/release-notes/release-notes-0.8.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@
**Note:** For full functionality, it is highly recommended to start LND with
the `--store-final-htlc-resolutions` flag enabled, which is disabled by default.

- [Limit-Order Constraints](https://github.com/lightninglabs/taproot-assets/pull/2048):
RFQ buy and sell orders can now carry explicit limit-price bounds
(`asset_rate_limit`) and minimum fill sizes (`asset_min_amt` /
`payment_min_amt`). Quotes that violate these constraints are rejected
with machine-readable reasons (`RATE_BOUND_MISS`, `MIN_FILL_NOT_MET`).
New fields are optional and backward-compatible; constraint validation
only activates when they are present.

## Functional Enhancements

- [Wallet Backup/Restore](https://github.com/lightninglabs/taproot-assets/pull/1980):
Expand Down Expand Up @@ -148,6 +156,13 @@
Add `RemoveMessage` RPC to the auth mailbox service. Receivers can
authenticate with a Schnorr signature to delete their own messages by ID.

- [PR#2048](https://github.com/lightninglabs/taproot-assets/pull/2048):
Add `asset_rate_limit` to `AddAssetBuyOrder` and `AddAssetSellOrder`
requests. Add `asset_min_amt` to buy orders and `payment_min_amt`
to sell orders. Add `asset_rate_limit` and min fill fields to
`PortfolioPilot.ResolveRequest` for constraint forwarding. Add
`RATE_BOUND_MISS` and `MIN_FILL_NOT_MET` to `QuoteRespStatus`.

## tapcli Additions

- [Wallet Backup CLI](https://github.com/lightninglabs/taproot-assets/pull/1980):
Expand Down Expand Up @@ -331,6 +346,10 @@
New integration test `testForwardingEventHistory` verifies that forwarding events are
properly logged when routing asset payments.

- [PR#2048](https://github.com/lightninglabs/taproot-assets/pull/2048):
Add unit, property-based, and integration tests for limit-order
constraint fields.

## Database

- [forwards table](https://github.com/lightninglabs/taproot-assets/pull/1921):
Expand Down
4 changes: 4 additions & 0 deletions itest/custom_channels/custom_channels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ var testCases = []*ccTestCase{
name: "invoice quote expiry mismatch",
test: testCustomChannelsInvoiceQuoteExpiryMismatch,
},
{
name: "limit constraints",
test: testCustomChannelsLimitConstraints,
},
{
name: "fee",
test: testCustomChannelsFee,
Expand Down
255 changes: 255 additions & 0 deletions itest/custom_channels/limit_constraints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
//go:build itest

package custom_channels

import (
"context"
"fmt"
"math"
"math/big"
"slices"
"time"

"github.com/lightninglabs/taproot-assets/asset"
"github.com/lightninglabs/taproot-assets/itest"
"github.com/lightninglabs/taproot-assets/proof"
"github.com/lightninglabs/taproot-assets/rfqmath"
"github.com/lightninglabs/taproot-assets/rfqmsg"
"github.com/lightninglabs/taproot-assets/taprpc"
"github.com/lightninglabs/taproot-assets/taprpc/mintrpc"
"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/lightningnetwork/lnd/lntest/node"
"github.com/lightningnetwork/lnd/lntest/port"
"github.com/stretchr/testify/require"
)

// testCustomChannelsLimitConstraints verifies that RFQ limit-order
// constraints (asset_rate_limit, payment_min_amt) work correctly in
// the context of real asset channels. It negotiates a sell quote with
// satisfied constraints, then sends a payment using that quote.
//
//nolint:lll
func testCustomChannelsLimitConstraints(_ context.Context,
net *itest.IntegratedNetworkHarness, t *ccHarnessTest) {

usdMetaData := &taprpc.AssetMeta{
Data: []byte(`{
"description":"USD stablecoin for limit constraint test"
}`),
Type: taprpc.AssetMetaType_META_TYPE_JSON,
}

const decimalDisplay = 6
tcAsset := &mintrpc.MintAsset{
AssetType: taprpc.AssetType_NORMAL,
Name: "USD-limits",
AssetMeta: usdMetaData,
Amount: 1_000_000_000_000,
DecimalDisplay: decimalDisplay,
}

oracleAddr := fmt.Sprintf(
"localhost:%d", port.NextAvailablePort(),
)
oracle := itest.NewOracleHarness(oracleAddr)
oracle.Start(t.t)
t.t.Cleanup(oracle.Stop)

lndArgs := slices.Clone(lndArgsTemplate)
tapdArgs := slices.Clone(tapdArgsTemplateNoOracle)
tapdArgs = append(tapdArgs, fmt.Sprintf(
"--experimental.rfq.priceoracleaddress=rfqrpc://%s",
oracleAddr,
))
tapdArgs = append(
tapdArgs,
"--experimental.rfq.priceoracletlsinsecure",
)

charliePort := port.NextAvailablePort()
tapdArgs = append(tapdArgs, fmt.Sprintf(
"--proofcourieraddr=%s://%s",
proof.UniverseRpcCourierType,
fmt.Sprintf(node.ListenerFormat, charliePort),
))

// Topology: Charlie --[assets]--> Dave --[sats]--> Erin
charlieLndArgs := slices.Clone(lndArgs)
charlieLndArgs = append(charlieLndArgs, fmt.Sprintf(
"--rpclisten=127.0.0.1:%d", charliePort,
))
charlie := net.NewNode("Charlie", charlieLndArgs, tapdArgs)
dave := net.NewNode("Dave", lndArgs, tapdArgs)
erin := net.NewNode("Erin", lndArgs, tapdArgs)

nodes := []*itest.IntegratedNode{charlie, dave, erin}
connectAllNodes(t.t, net, nodes)
fundAllNodes(t.t, net, nodes)

// Open a normal BTC channel between Dave and Erin.
const btcChannelFundingAmount = 10_000_000
chanPointDE := openChannelAndAssert(
t, net, dave, erin, lntest.OpenChannelParams{
Amt: btcChannelFundingAmount,
SatPerVByte: 5,
},
)
defer closeChannelAndAssert(t, net, dave, chanPointDE, false)

assertChannelKnown(t.t, charlie, chanPointDE)

// Mint on Charlie.
mintedAssets := itest.MintAssetsConfirmBatch(
t.t, net.Miner.Client, asTapd(charlie),
[]*mintrpc.MintAssetRequest{
{Asset: tcAsset},
},
)
usdAsset := mintedAssets[0]
assetID := usdAsset.AssetGenesis.AssetId

var id asset.ID
copy(id[:], assetID)

// Oracle price: ~66,548.40 USD/BTC with decimal display 6.
salePrice := rfqmath.NewBigIntFixedPoint(65_217_43, 2)
purchasePrice := rfqmath.NewBigIntFixedPoint(67_879_37, 2)
factor := rfqmath.NewBigInt(
big.NewInt(int64(math.Pow10(decimalDisplay))),
)
salePrice.Coefficient = salePrice.Coefficient.Mul(factor)
purchasePrice.Coefficient = purchasePrice.Coefficient.Mul(
factor,
)
oracle.SetPrice(
asset.NewSpecifierFromId(id), purchasePrice, salePrice,
)

t.Logf("Syncing universes...")
syncUniverses(t.t, charlie, dave, erin)

// Send assets to Dave so he has a balance.
const sendAmount = uint64(400_000_000)
charlieFundingAmount := usdAsset.Amount - sendAmount

ctxb := context.Background()
daveAddr, err := dave.NewAddr(ctxb, &taprpc.NewAddrRequest{
Amt: sendAmount,
AssetId: assetID,
ProofCourierAddr: fmt.Sprintf(
"%s://%s", proof.UniverseRpcCourierType,
charlie.RPCAddr(),
),
})
require.NoError(t.t, err)

sendResp, err := charlie.SendAsset(
ctxb, &taprpc.SendAssetRequest{
TapAddrs: []string{daveAddr.Encoded},
},
)
require.NoError(t.t, err)
itest.ConfirmAndAssertOutboundTransfer(
t.t, net.Miner.Client, asTapd(charlie), sendResp,
assetID,
[]uint64{usdAsset.Amount - sendAmount, sendAmount},
0, 1,
)
itest.AssertNonInteractiveRecvComplete(t.t, asTapd(dave), 1)

// Open asset channel Charlie → Dave.
t.Logf("Opening asset channel Charlie → Dave...")
net.EnsureConnected(t.t, charlie, dave)
fundResp, err := charlie.FundChannel(
ctxb, &tchrpc.FundChannelRequest{
AssetAmount: charlieFundingAmount,
AssetId: assetID,
PeerPubkey: dave.PubKey[:],
FeeRateSatPerVbyte: 5,
PushSat: DefaultPushSat,
},
)
require.NoError(t.t, err)
t.Logf("Funded channel: %v", fundResp)

mineBlocks(t, net, 6, 1)

chanPointCD := &lnrpc.ChannelPoint{
OutputIndex: uint32(fundResp.OutputIndex),
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
FundingTxidStr: fundResp.Txid,
},
}

logBalance(t.t, nodes, assetID, "after channel open")

// -----------------------------------------------------------------
// Negotiate a sell order from Charlie with constraints.
// Rate limit is set well above the oracle rate (ceiling for
// sell), so the constraint is satisfied.
// -----------------------------------------------------------------
t.Logf("Negotiating sell order with constraints...")

inOneHour := time.Now().Add(time.Hour)
sellResp, err := asTapd(charlie).RfqClient.AddAssetSellOrder(
ctxb, &rfqrpc.AddAssetSellOrderRequest{
AssetSpecifier: &rfqrpc.AssetSpecifier{
Id: &rfqrpc.AssetSpecifier_AssetId{
AssetId: assetID,
},
},
PaymentMaxAmt: 180_000_000,
AssetRateLimit: &rfqrpc.FixedPoint{
// Ceiling well above oracle rate.
Coefficient: "100000000000000",
Scale: 2,
},
Expiry: uint64(inOneHour.Unix()),
PeerPubKey: dave.PubKey[:],
TimeoutSeconds: 10,
},
)
require.NoError(t.t, err, "sell order with constraints")

accepted := sellResp.GetAcceptedQuote()
require.NotNil(t.t, accepted, "expected accepted sell quote")
t.Logf("Sell quote accepted: scid=%d", accepted.Scid)

// -----------------------------------------------------------------
// Pay an invoice using the pre-negotiated quote.
// -----------------------------------------------------------------
t.Logf("Paying invoice with constrained quote...")

// Erin has no asset channel, so we create a regular BTC
// invoice. Charlie pays it with assets via the sell quote.
const invoiceMsat = 100_000_000 // 100K sats
invoiceResp, err := erin.LightningClient.AddInvoice(
ctxb, &lnrpc.Invoice{
ValueMsat: invoiceMsat,
},
)
require.NoError(t.t, err)

var quoteID rfqmsg.ID
copy(quoteID[:], accepted.Id)

numUnits, _ := payInvoiceWithAssets(
t.t, charlie, dave,
invoiceResp.PaymentRequest,
assetID, withRFQ(quoteID),
)
require.Greater(t.t, numUnits, uint64(0))

logBalance(t.t, nodes, assetID, "after payment")
t.Logf("Payment completed: %d asset units sent", numUnits)

// Close channels.
closeAssetChannelAndAssert(
t, net, charlie, dave, chanPointCD,
[][]byte{assetID}, nil, charlie,
noOpCoOpCloseBalanceCheck,
)
}
14 changes: 12 additions & 2 deletions itest/portfolio_pilot_harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,19 @@ func (p *portfolioPilotHarness) ResolveRequest(_ context.Context,
var hint *pilotrpc.AssetRate
switch r := req.GetRequest().(type) {
case *pilotrpc.ResolveRequestRequest_BuyRequest:
hint = r.BuyRequest.GetAssetRateHint()
br := r.BuyRequest
hint = br.GetAssetRateHint()
log.Infof("ResolveRequest buy: max=%d min=%d "+
"rate_limit=%v", br.GetAssetMaxAmount(),
br.GetAssetMinAmount(),
br.GetAssetRateLimit())
case *pilotrpc.ResolveRequestRequest_SellRequest:
hint = r.SellRequest.GetAssetRateHint()
sr := r.SellRequest
hint = sr.GetAssetRateHint()
log.Infof("ResolveRequest sell: max=%d min=%d "+
"rate_limit=%v", sr.GetPaymentMaxAmount(),
sr.GetPaymentMinAmount(),
sr.GetAssetRateLimit())
default:
return nil, fmt.Errorf("unknown request type: %T", r)
}
Expand Down
Loading
Loading