Skip to content

Commit b7225d3

Browse files
committed
itest: RFQ forwards integration tests
1 parent 4384102 commit b7225d3

File tree

2 files changed

+363
-0
lines changed

2 files changed

+363
-0
lines changed

itest/rfq_forwards_test.go

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
package itest
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"time"
8+
9+
"github.com/lightninglabs/taproot-assets/asset"
10+
"github.com/lightninglabs/taproot-assets/fn"
11+
"github.com/lightninglabs/taproot-assets/rfqmsg"
12+
"github.com/lightninglabs/taproot-assets/taprpc/mintrpc"
13+
oraclerpc "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc"
14+
"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
15+
"github.com/lightningnetwork/lnd/lnrpc"
16+
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
17+
"github.com/lightningnetwork/lnd/lntest/port"
18+
"github.com/lightningnetwork/lnd/tlv"
19+
"github.com/stretchr/testify/mock"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
// testRfqForwardHistory tests that RFQ forwards are properly logged and can be
24+
// queried via the QueryRfqForwards RPC endpoint.
25+
//
26+
// The procedure is as follows:
27+
// 1. Alice sends an asset sell order to Bob.
28+
// 2. Bob accepts the quote, creating a purchase policy.
29+
// 3. Alice sends a payment with asset custom records to Carol via Bob.
30+
// 4. Bob intercepts the HTLC and validates it against the accepted quote.
31+
// 5. The payment settles successfully.
32+
// 6. We query Bob's forward history and verify the forward was recorded.
33+
// 7. We test query filters (timestamp, peer, asset_id) and pagination.
34+
func testRfqForwardHistory(t *harnessTest) {
35+
oracleAddr := fmt.Sprintf("localhost:%d", port.NextAvailablePort())
36+
oracle := newOracleHarness(oracleAddr)
37+
oracle.start(t.t)
38+
t.t.Cleanup(oracle.stop)
39+
40+
oracleURL := fmt.Sprintf("rfqrpc://%s", oracleAddr)
41+
ts := newRfqTestScenario(t, WithRfqOracleServer(oracleURL))
42+
43+
// Mint an asset with Alice's tapd node.
44+
rpcAssets := MintAssetsConfirmBatch(
45+
t.t, t.lndHarness.Miner().Client, ts.AliceTapd,
46+
[]*mintrpc.MintAssetRequest{issuableAssets[0]},
47+
)
48+
mintedAssetIdBytes := rpcAssets[0].AssetGenesis.AssetId
49+
50+
var mintedAssetId asset.ID
51+
copy(mintedAssetId[:], mintedAssetIdBytes[:])
52+
53+
ctxb := context.Background()
54+
ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout)
55+
defer cancel()
56+
57+
// Add an asset buy offer to Bob's tapd node.
58+
_, err := ts.BobTapd.AddAssetBuyOffer(
59+
ctxt, &rfqrpc.AddAssetBuyOfferRequest{
60+
AssetSpecifier: &rfqrpc.AssetSpecifier{
61+
Id: &rfqrpc.AssetSpecifier_AssetId{
62+
AssetId: mintedAssetIdBytes,
63+
},
64+
},
65+
MaxUnits: 1000,
66+
},
67+
)
68+
require.NoError(t.t, err)
69+
70+
aliceEventNtfns, err := ts.AliceTapd.SubscribeRfqEventNtfns(
71+
ctxb, &rfqrpc.SubscribeRfqEventNtfnsRequest{},
72+
)
73+
require.NoError(t.t, err)
74+
75+
// Alice sends a sell order to Bob.
76+
askAmt := uint64(42000)
77+
sellOrderExpiry := uint64(time.Now().Add(24 * time.Hour).Unix())
78+
79+
sellReq := &rfqrpc.AddAssetSellOrderRequest{
80+
AssetSpecifier: &rfqrpc.AssetSpecifier{
81+
Id: &rfqrpc.AssetSpecifier_AssetId{
82+
AssetId: mintedAssetIdBytes,
83+
},
84+
},
85+
PaymentMaxAmt: askAmt,
86+
Expiry: sellOrderExpiry,
87+
PeerPubKey: ts.BobLnd.PubKey[:],
88+
TimeoutSeconds: uint32(rfqTimeout.Seconds()),
89+
SkipAssetChannelCheck: true,
90+
PriceOracleMetadata: "forward-history-test",
91+
}
92+
93+
// Set up the expected oracle calls.
94+
buySpecifier := &oraclerpc.AssetSpecifier{
95+
Id: &oraclerpc.AssetSpecifier_AssetId{
96+
AssetId: mintedAssetIdBytes,
97+
},
98+
}
99+
btcSpecifier := &oraclerpc.AssetSpecifier{
100+
Id: &oraclerpc.AssetSpecifier_AssetId{
101+
AssetId: bytes.Repeat([]byte{0}, 32),
102+
},
103+
}
104+
105+
expiryTimestamp := uint64(time.Now().Add(time.Minute).Unix())
106+
mockResult := &oraclerpc.QueryAssetRatesResponse{
107+
Result: &oraclerpc.QueryAssetRatesResponse_Ok{
108+
Ok: &oraclerpc.QueryAssetRatesOkResponse{
109+
AssetRates: &oraclerpc.AssetRates{
110+
SubjectAssetRate: &oraclerpc.FixedPoint{
111+
Coefficient: "1000000",
112+
Scale: 3,
113+
},
114+
ExpiryTimestamp: expiryTimestamp,
115+
},
116+
},
117+
},
118+
}
119+
120+
oracle.On(
121+
"QueryAssetRates", oraclerpc.TransactionType_SALE,
122+
buySpecifier, mock.Anything, btcSpecifier,
123+
askAmt, mock.Anything,
124+
oraclerpc.Intent_INTENT_PAY_INVOICE_HINT,
125+
mock.Anything, "forward-history-test",
126+
).Return(mockResult, nil).Once()
127+
128+
oracle.On(
129+
"QueryAssetRates", oraclerpc.TransactionType_PURCHASE,
130+
buySpecifier, mock.Anything, btcSpecifier,
131+
askAmt, mock.Anything,
132+
oraclerpc.Intent_INTENT_PAY_INVOICE,
133+
mock.Anything, "forward-history-test",
134+
).Return(mockResult, nil).Once()
135+
136+
oracle.On(
137+
"QueryAssetRates", oraclerpc.TransactionType_SALE,
138+
buySpecifier, mock.Anything, btcSpecifier,
139+
askAmt, mock.Anything,
140+
oraclerpc.Intent_INTENT_PAY_INVOICE_QUALIFY,
141+
mock.Anything, "forward-history-test",
142+
).Return(mockResult, nil).Once()
143+
144+
defer oracle.AssertExpectations(t.t)
145+
146+
_, err = ts.AliceTapd.AddAssetSellOrder(ctxt, sellReq)
147+
require.NoError(t.t, err)
148+
149+
// Wait for Alice to receive the quote accept from Bob.
150+
BeforeTimeout(t.t, func() {
151+
event, err := aliceEventNtfns.Recv()
152+
require.NoError(t.t, err)
153+
_, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedSellQuote)
154+
require.True(t.t, ok)
155+
}, rfqTimeout)
156+
157+
acceptedQuotes, err := ts.AliceTapd.QueryPeerAcceptedQuotes(
158+
ctxt, &rfqrpc.QueryPeerAcceptedQuotesRequest{},
159+
)
160+
require.NoError(t.t, err)
161+
require.Len(t.t, acceptedQuotes.SellQuotes, 1)
162+
163+
acceptedQuote := acceptedQuotes.SellQuotes[0]
164+
var acceptedQuoteId rfqmsg.ID
165+
copy(acceptedQuoteId[:], acceptedQuote.Id[:])
166+
167+
// Record timestamp before payment for filtering tests.
168+
timestampBeforePayment := time.Now().Unix()
169+
170+
bobEventNtfns, err := ts.BobTapd.SubscribeRfqEventNtfns(
171+
ctxb, &rfqrpc.SubscribeRfqEventNtfnsRequest{},
172+
)
173+
require.NoError(t.t, err)
174+
175+
// Carol generates an invoice for Alice to settle via Bob.
176+
addInvoiceResp := ts.CarolLnd.RPC.AddInvoice(&lnrpc.Invoice{
177+
ValueMsat: int64(askAmt),
178+
})
179+
invoice := ts.CarolLnd.RPC.LookupInvoice(addInvoiceResp.RHash)
180+
payReq := ts.CarolLnd.RPC.DecodePayReq(invoice.PaymentRequest)
181+
182+
// Construct route: Alice -> Bob -> Carol.
183+
routeBuildResp := ts.AliceLnd.RPC.BuildRoute(
184+
&routerrpc.BuildRouteRequest{
185+
AmtMsat: int64(askAmt),
186+
HopPubkeys: [][]byte{
187+
ts.BobLnd.PubKey[:],
188+
ts.CarolLnd.PubKey[:],
189+
},
190+
PaymentAddr: payReq.PaymentAddr,
191+
},
192+
)
193+
194+
// Construct first hop custom records.
195+
assetAmounts := []*rfqmsg.AssetBalance{
196+
rfqmsg.NewAssetBalance(mintedAssetId, 42),
197+
}
198+
htlcCustomRecords := rfqmsg.NewHtlc(
199+
assetAmounts, fn.Some(acceptedQuoteId), fn.None[[]rfqmsg.ID](),
200+
)
201+
firstHopCustomRecords, err := tlv.RecordsToMap(
202+
htlcCustomRecords.Records(),
203+
)
204+
require.NoError(t.t, err)
205+
206+
// Send the payment.
207+
sendAttempt := ts.AliceLnd.RPC.SendToRouteV2(
208+
&routerrpc.SendToRouteRequest{
209+
PaymentHash: invoice.RHash,
210+
Route: routeBuildResp.Route,
211+
FirstHopCustomRecords: firstHopCustomRecords,
212+
},
213+
)
214+
require.Equal(t.t, lnrpc.HTLCAttempt_SUCCEEDED, sendAttempt.Status)
215+
216+
// Wait for Bob to accept the HTLC.
217+
BeforeTimeout(t.t, func() {
218+
event, err := bobEventNtfns.Recv()
219+
require.NoError(t.t, err)
220+
_, ok := event.Event.(*rfqrpc.RfqEvent_AcceptHtlc)
221+
require.True(t.t, ok)
222+
}, rfqTimeout)
223+
224+
// Confirm Carol received the payment.
225+
invoice = ts.CarolLnd.RPC.LookupInvoice(addInvoiceResp.RHash)
226+
require.Equal(t.t, lnrpc.Invoice_SETTLED, invoice.State)
227+
228+
timestampAfterPayment := time.Now().Unix()
229+
230+
// Wait for the forward to be logged.
231+
var forwardsResp *rfqrpc.QueryRfqForwardsResponse
232+
BeforeTimeout(t.t, func() {
233+
var err error
234+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
235+
ctxt, &rfqrpc.QueryRfqForwardsRequest{},
236+
)
237+
require.NoError(t.t, err)
238+
require.Len(t.t, forwardsResp.Forwards, 1)
239+
}, rfqTimeout)
240+
241+
fwd := forwardsResp.Forwards[0]
242+
243+
// Verify forward record fields.
244+
require.Equal(t.t, acceptedQuote.Id, fwd.RfqId)
245+
require.Equal(t.t, rfqrpc.RfqPolicyType_RFQ_POLICY_TYPE_PURCHASE,
246+
fwd.PolicyType)
247+
require.Equal(t.t, ts.AliceLnd.PubKeyStr, fwd.Peer)
248+
require.NotNil(t.t, fwd.AssetSpec)
249+
require.Equal(t.t, mintedAssetIdBytes, fwd.AssetSpec.Id)
250+
require.Equal(t.t, uint64(42), fwd.AssetAmt)
251+
252+
// Verify opened_at is within the expected range.
253+
require.GreaterOrEqual(t.t,
254+
fwd.OpenedAt,
255+
uint64(timestampBeforePayment),
256+
)
257+
require.LessOrEqual(t.t,
258+
fwd.OpenedAt,
259+
uint64(timestampAfterPayment+10),
260+
)
261+
262+
// Verify settled_at is within the expected range and after opened_at.
263+
require.GreaterOrEqual(t.t,
264+
fwd.SettledAt,
265+
fwd.OpenedAt,
266+
)
267+
require.LessOrEqual(t.t,
268+
fwd.SettledAt,
269+
uint64(timestampAfterPayment+10),
270+
)
271+
272+
// Verify failed_at is 0 for a successful forward.
273+
require.Zero(t.t, fwd.FailedAt)
274+
275+
// Verify amount fields are set and reasonable.
276+
require.Greater(t.t, fwd.AmtInMsat, uint64(0))
277+
require.Greater(t.t, fwd.AmtOutMsat, uint64(0))
278+
279+
require.NotNil(t.t, fwd.Rate)
280+
require.Equal(t.t, int64(1), forwardsResp.TotalCount)
281+
282+
// Test timestamp filters.
283+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
284+
ctxt, &rfqrpc.QueryRfqForwardsRequest{
285+
MinTimestamp: uint64(timestampBeforePayment),
286+
},
287+
)
288+
require.NoError(t.t, err)
289+
require.Len(t.t, forwardsResp.Forwards, 1)
290+
291+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
292+
ctxt, &rfqrpc.QueryRfqForwardsRequest{
293+
MaxTimestamp: uint64(timestampBeforePayment - 10),
294+
},
295+
)
296+
require.NoError(t.t, err)
297+
require.Len(t.t, forwardsResp.Forwards, 0)
298+
299+
// Test peer filter.
300+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
301+
ctxt, &rfqrpc.QueryRfqForwardsRequest{
302+
Peer: ts.AliceLnd.PubKey[:],
303+
},
304+
)
305+
require.NoError(t.t, err)
306+
require.Len(t.t, forwardsResp.Forwards, 1)
307+
308+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
309+
ctxt, &rfqrpc.QueryRfqForwardsRequest{
310+
Peer: ts.CarolLnd.PubKey[:],
311+
},
312+
)
313+
require.NoError(t.t, err)
314+
require.Len(t.t, forwardsResp.Forwards, 0)
315+
316+
// Test asset filter.
317+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
318+
ctxt, &rfqrpc.QueryRfqForwardsRequest{
319+
AssetSpecifier: &rfqrpc.AssetSpecifier{
320+
Id: &rfqrpc.AssetSpecifier_AssetId{
321+
AssetId: mintedAssetIdBytes,
322+
},
323+
},
324+
},
325+
)
326+
require.NoError(t.t, err)
327+
require.Len(t.t, forwardsResp.Forwards, 1)
328+
329+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
330+
ctxt, &rfqrpc.QueryRfqForwardsRequest{
331+
AssetSpecifier: &rfqrpc.AssetSpecifier{
332+
Id: &rfqrpc.AssetSpecifier_AssetId{
333+
AssetId: bytes.Repeat([]byte{0xab}, 32),
334+
},
335+
},
336+
},
337+
)
338+
require.NoError(t.t, err)
339+
require.Len(t.t, forwardsResp.Forwards, 0)
340+
341+
// Test pagination.
342+
forwardsResp, err = ts.BobTapd.QueryRfqForwards(
343+
ctxt, &rfqrpc.QueryRfqForwardsRequest{Limit: 100, Offset: 10},
344+
)
345+
require.NoError(t.t, err)
346+
require.Len(t.t, forwardsResp.Forwards, 0)
347+
require.Equal(t.t, int64(1), forwardsResp.TotalCount)
348+
349+
// Alice should have no forwards (she's not an edge node).
350+
aliceForwardsResp, err := ts.AliceTapd.QueryRfqForwards(
351+
ctxt, &rfqrpc.QueryRfqForwardsRequest{},
352+
)
353+
require.NoError(t.t, err)
354+
require.Len(t.t, aliceForwardsResp.Forwards, 0)
355+
356+
// Cleanup.
357+
require.NoError(t.t, aliceEventNtfns.CloseSend())
358+
require.NoError(t.t, bobEventNtfns.CloseSend())
359+
}

itest/test_list_on_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,10 @@ var allTestCases = []*testCase{
351351
name: "rfq negotiation group key",
352352
test: testRfqNegotiationGroupKey,
353353
},
354+
{
355+
name: "rfq forward history",
356+
test: testRfqForwardHistory,
357+
},
354358
{
355359
name: "multi signature on all levels",
356360
test: testMultiSignature,

0 commit comments

Comments
 (0)