Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement PrevId[] UTXO selection for FundVirtualPSBT RPC method; valid for single PrevId at first #1172

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
177 changes: 153 additions & 24 deletions itest/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -623,16 +623,27 @@ func testMintBatchAndTransfer(t *harnessTest) {
require.True(t.t, proto.Equal(originalBatch, afterBatch))
}

// testAssetBalances tests the balance retrieval functionality for issued
// assets. The function mints two batches of assets and asserts if the tapcli
// `assets balance` returns the correct balances. It then funds a vPSBT, putting
// a lease on one of the two batches. It then asserts whether the endpoint still
// returns the correct balances, taking into account the `include_leased` flag.
// testAssetBalances validates the balance retrieval and virtual PSBT funding
// functionality for issued assets. The test performs the following steps:
// 1. Mints two batches of assets and verifies that the tapcli
// `assets balance` returns the correct balances.
// 2. Tests funding a vPSBT, putting a lease on one of the two batches, with
// the `FundVirtualPsbt` RPC for various scenarios:
// - Fails if the Inputs field contains a nil Outpoint.
// - Fails if the Inputs field contains an invalid Outpoint.
// - Fails if the Inputs field contains an invalid short AssetId.
// - Fails if the Inputs field contains an invalid short ScriptKey.
// - Succeeds if a valid Outpoint is provided in the Inputs field.
// - Succeeds if the Inputs field is not provided at all, using asset
// coin selection instead.
// 3. Ensures that leased assets are reflected correctly in the balance
// retrieval, with and without the `include_leased` flag.
func testAssetBalances(t *harnessTest) {
ctxb := context.Background()
ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout)
defer cancel()

// Mint assets for testing.
rpcSimpleAssets := MintAssetsConfirmBatch(
t.t, t.lndHarness.Miner().Client, t.tapd, simpleAssets,
)
Expand All @@ -648,8 +659,18 @@ func testAssetBalances(t *harnessTest) {
t.t, t.tapd, rpcSimpleAssets, rpcIssuableAssets, false,
)

// Check chain anchor for the target asset.
out, err := wire.NewOutPointFromString(
targetAsset.ChainAnchor.AnchorOutpoint,
)
require.NoError(t.t, err)

var (
targetAssetGenesis = targetAsset.AssetGenesis
assetId = targetAssetGenesis.AssetId
scriptKey = targetAsset.ScriptKey
anchorTxid = out.Hash.CloneBytes()
anchorVout = out.Index
aliceTapd = t.tapd
bobLnd = t.lndHarness.Bob
)
Expand All @@ -668,31 +689,139 @@ func testAssetBalances(t *harnessTest) {
})
require.NoError(t.t, err)

// Now we can create our virtual transaction and ask Alice's tapd to
// fund it.
recipients := map[string]uint64{
bobAddr.Encoded: bobAddr.Amount,
}
_, err = aliceTapd.FundVirtualPsbt(
ctxt, &wrpc.FundVirtualPsbtRequest{
Template: &wrpc.FundVirtualPsbtRequest_Raw{
Raw: &wrpc.TxTemplate{
Recipients: recipients,

subTests := []struct {
name string
inputs []*wrpc.PrevId
expectError bool
expectedErrMsg string
}{
{
name: "Fail if Inputs are provided but Outpoint is nil",
inputs: []*wrpc.PrevId{
{
Outpoint: nil,
Id: assetId,
ScriptKey: scriptKey,
},
},
expectError: true,
expectedErrMsg: "index 0 has a nil Outpoint",
},
)
require.NoError(t.t, err)
{
name: "Fail if Inputs contain an invalid Outpoint",
inputs: []*wrpc.PrevId{
{
Outpoint: &taprpc.OutPoint{
Txid: []byte(
"invalid_txid",
),
OutputIndex: anchorVout,
},
Id: assetId,
ScriptKey: scriptKey,
},
},
expectError: true,
expectedErrMsg: "invalid Txid",
},
{
name: "Fail if AssetId is too short",
inputs: []*wrpc.PrevId{
{
Outpoint: &taprpc.OutPoint{
Txid: anchorTxid,
OutputIndex: anchorVout,
},
Id: []byte{1, 2},
ScriptKey: scriptKey,
},
},
expectError: true,
expectedErrMsg: "invalid asset ID",
},
{
name: "Fail if ScriptKey is too short",
inputs: []*wrpc.PrevId{
{
Outpoint: &taprpc.OutPoint{
Txid: anchorTxid,
OutputIndex: anchorVout,
},
Id: assetId,
ScriptKey: []byte{1, 2},
},
},
expectError: true,
expectedErrMsg: "invalid script key",
},
{
name: "Succeed if a valid Outpoint is provided",
inputs: []*wrpc.PrevId{
{
Outpoint: &taprpc.OutPoint{
Txid: anchorTxid,
OutputIndex: anchorVout,
},
Id: assetId,
ScriptKey: scriptKey,
},
},
expectError: false,
},
{
name: "Succeed if no Inputs are provided",
inputs: nil,
expectError: false,
},
}

// With a transaction funding should have led to a lease on the simple
// assets, we'll use the balance calls to ensure that we're able to
// retrieve the proper balances.
rpcEmptyAssets := []*taprpc.Asset{}
AssertAssetBalances(
t.t, t.tapd, rpcEmptyAssets, rpcIssuableAssets, false,
)
for _, tt := range subTests {
// Now we can create our virtual transaction and ask Alice's
// tapd to fund it.
_, err := aliceTapd.FundVirtualPsbt(
ctxt, &wrpc.FundVirtualPsbtRequest{
Template: &wrpc.FundVirtualPsbtRequest_Raw{
Raw: &wrpc.TxTemplate{
Recipients: recipients,
Inputs: tt.inputs,
},
},
},
)

AssertAssetBalances(
t.t, t.tapd, rpcSimpleAssets, rpcIssuableAssets, true,
)
if tt.expectError {
require.ErrorContains(t.t, err, tt.expectedErrMsg)
} else {
require.NoError(t.t, err)

// With a transaction funding should have led to a
// lease on the simple assets, we'll use the balance
// calls to ensure that we're able to retrieve the
// proper balances.
rpcEmptyAssets := []*taprpc.Asset{}
AssertAssetBalances(
t.t, t.tapd, rpcEmptyAssets, rpcIssuableAssets,
false,
)
AssertAssetBalances(
t.t, t.tapd, rpcSimpleAssets, rpcIssuableAssets,
true,
)

// Unlock the input if provided so it can be reused.
for _, input := range tt.inputs {
_, err = aliceTapd.RemoveUTXOLease(
ctxb,
&wrpc.RemoveUTXOLeaseRequest{
Outpoint: input.Outpoint,
},
)
require.NoError(t.t, err)
}
}
}
}
47 changes: 42 additions & 5 deletions rpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2048,10 +2048,47 @@ func (r *rpcServer) FundVirtualPsbt(ctx context.Context,
}

case req.GetRaw() != nil:
raw := req.GetRaw()
if len(raw.Inputs) > 0 {
return nil, fmt.Errorf("template inputs not yet " +
"supported")
var (
raw = req.GetRaw()
prevIDs []asset.PrevID
)
for i, input := range raw.Inputs {
if input.Outpoint == nil {
return nil, fmt.Errorf("input at index %d has "+
"a nil Outpoint", i)
}

hash, err := chainhash.NewHash(input.Outpoint.Txid)
if err != nil {
return nil, fmt.Errorf("input at index %d has "+
"invalid Txid: %w", i, err)
}

scriptKey, err := parseUserKey(input.ScriptKey)
if err != nil {
return nil, fmt.Errorf("input at index %d has "+
"invalid script key: %w", i, err)
}

if len(input.Id) != 32 {
return nil, fmt.Errorf("input at index %d has "+
"invalid asset ID of %d bytes, must "+
"be 32 bytes", i, len(input.Id))
}

// Decode the input into an asset.PrevID.
outpoint := wire.OutPoint{
Hash: *hash,
Index: input.Outpoint.OutputIndex,
}
prevID := asset.PrevID{
OutPoint: outpoint,
ID: asset.ID(input.Id),
habibitcoin marked this conversation as resolved.
Show resolved Hide resolved
ScriptKey: asset.ToSerialized(
scriptKey,
),
}
prevIDs = append(prevIDs, prevID)
}
if len(raw.Recipients) > 1 {
return nil, fmt.Errorf("only one recipient supported")
Expand All @@ -2074,7 +2111,7 @@ func (r *rpcServer) FundVirtualPsbt(ctx context.Context,
}

fundedVPkt, err = r.cfg.AssetWallet.FundAddressSend(
ctx, coinSelectType, addr,
ctx, coinSelectType, prevIDs, addr,
)
if err != nil {
return nil, fmt.Errorf("error funding address send: "+
Expand Down
43 changes: 42 additions & 1 deletion tapdb/assets_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1991,7 +1991,48 @@ func (a *AssetStore) ListEligibleCoins(ctx context.Context,
// have a block height of 0, so we set the minimum block height to 1.
assetFilter.MinAnchorHeight = sqlInt32(1)

return a.queryCommitments(ctx, assetFilter)
selectedCommitments, err := a.queryCommitments(ctx, assetFilter)
if err != nil {
return nil, fmt.Errorf("unable to query commitments: %w", err)
}

// If we want to restrict on specific inputs, we do the filtering now.
if len(constraints.PrevIDs) > 0 {
selectedCommitments = filterCommitmentsByPrevIDs(
selectedCommitments, constraints.PrevIDs,
)

// If this results in an empty list, we return the same error we
// would if there were no coins found without the filter.
if len(selectedCommitments) == 0 {
return nil, tapfreighter.ErrMatchingAssetsNotFound
}
}

return selectedCommitments, nil
}

// filterCommitmentsByPrevIDs filters the given commitments by the previous IDs
// given.
func filterCommitmentsByPrevIDs(commitments []*tapfreighter.AnchoredCommitment,
prevIDs []asset.PrevID) []*tapfreighter.AnchoredCommitment {

prevIDMatches := func(p asset.PrevID,
c *tapfreighter.AnchoredCommitment) bool {

return p.OutPoint == c.AnchorPoint && p.ID == c.Asset.ID() &&
p.ScriptKey == asset.ToSerialized(
c.Asset.ScriptKey.PubKey,
)
}

commitmentInList := func(c *tapfreighter.AnchoredCommitment) bool {
return fn.Any(prevIDs, func(p asset.PrevID) bool {
return prevIDMatches(p, c)
})
}

return fn.Filter(commitments, commitmentInList)
}

// LeaseCoins leases/locks/reserves coins for the given lease owner until the
Expand Down
2 changes: 1 addition & 1 deletion tapfreighter/chain_porter.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,7 +994,7 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {
"address parcel")
}
fundSendRes, err := p.cfg.AssetWallet.FundAddressSend(
ctx, tapsend.Bip86Only, addrParcel.destAddrs...,
ctx, tapsend.Bip86Only, nil, addrParcel.destAddrs...,
)
if err != nil {
return nil, fmt.Errorf("unable to fund address send: "+
Expand Down
1 change: 1 addition & 0 deletions tapfreighter/coin_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func (s *CoinSelect) SelectCoins(ctx context.Context,
AssetSpecifier: constraints.AssetSpecifier,
MinAmt: 1,
CoinSelectType: constraints.CoinSelectType,
PrevIDs: constraints.PrevIDs,
}
eligibleCommitments, err := s.coinLister.ListEligibleCoins(
ctx, listConstraints,
Expand Down
3 changes: 3 additions & 0 deletions tapfreighter/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type CommitmentConstraints struct {
// to satisfy the constraints.
MinAmt uint64

// PrevIDs are the set of inputs allowed to be used.
PrevIDs []asset.PrevID

// CoinSelectType is the type of coins that should be selected.
CoinSelectType tapsend.CoinSelectType
}
Expand Down
9 changes: 8 additions & 1 deletion tapfreighter/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ type Wallet interface {
// asset re-anchors and the Taproot Asset level commitment of the
// selected assets.
FundAddressSend(ctx context.Context,
coinSelectType tapsend.CoinSelectType,
coinSelectType tapsend.CoinSelectType, prevIDs []asset.PrevID,
receiverAddrs ...*address.Tap) (*FundedVPacket, error)

// FundPacket funds a virtual transaction, selecting assets to spend
Expand Down Expand Up @@ -236,6 +236,7 @@ type FundedVPacket struct {
// NOTE: This is part of the Wallet interface.
func (f *AssetWallet) FundAddressSend(ctx context.Context,
coinSelectType tapsend.CoinSelectType,
prevIDs []asset.PrevID,
receiverAddrs ...*address.Tap) (*FundedVPacket, error) {

// We start by creating a new virtual transaction that will be used to
Expand All @@ -253,6 +254,11 @@ func (f *AssetWallet) FundAddressSend(ctx context.Context,
return nil, fmt.Errorf("unable to describe recipients: %w", err)
}

// We need to constrain the prevIDs if they are provided.
if len(prevIDs) > 0 {
fundDesc.PrevIDs = prevIDs
}

fundDesc.CoinSelectType = coinSelectType
fundedVPkt, err := f.FundPacket(ctx, fundDesc, vPkt)
if err != nil {
Expand Down Expand Up @@ -371,6 +377,7 @@ func (f *AssetWallet) FundPacket(ctx context.Context,
AssetSpecifier: fundDesc.AssetSpecifier,
MinAmt: fundDesc.Amount,
CoinSelectType: fundDesc.CoinSelectType,
PrevIDs: fundDesc.PrevIDs,
}

anchorVersion, err := tappsbt.CommitmentVersion(vPkt.Version)
Expand Down
3 changes: 3 additions & 0 deletions tapsend/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ type FundingDescriptor struct {
// Amount is the amount of the asset to transfer.
Amount uint64

// PrevIDs is the set of inputs that can be used to fund the transfer.
PrevIDs []asset.PrevID

// CoinSelectType specifies the type of coins that should be selected.
CoinSelectType CoinSelectType
}
Expand Down
Loading