From 87370e81caf746c8ef4bd81e6d168f0322db757f Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 23 Jun 2025 16:23:28 +0200 Subject: [PATCH 1/6] address+tapdb: add AddrByScriptKeyAndVersion Because we'll use the address' script key as the bare/raw public key that will be tweaked for each individual output of an address v2 send, we'll need to be able to find addresses by that script key. We'll also make the script key unique for v2 addresses to avoid multiple records being returned here. --- address/book.go | 14 ++++ address/book_test.go | 7 ++ tapdb/addrs.go | 119 ++++++++++++++++++++++------- tapdb/addrs_test.go | 84 +++++++++++++++++++++ tapdb/sqlc/addrs.sql.go | 140 +++++++++++++++++------------------ tapdb/sqlc/querier.go | 2 +- tapdb/sqlc/queries/addrs.sql | 18 +++-- 7 files changed, 278 insertions(+), 106 deletions(-) diff --git a/address/book.go b/address/book.go index c19d5eb99..0fe2dee1c 100644 --- a/address/book.go +++ b/address/book.go @@ -127,6 +127,12 @@ type Storage interface { AddrByTaprootOutput(ctx context.Context, key *btcec.PublicKey) (*AddrWithKeyInfo, error) + // AddrByScriptKeyAndVersion returns a single address based on its + // script key and version or a sql.ErrNoRows error if no such address + // exists. + AddrByScriptKeyAndVersion(context.Context, *btcec.PublicKey, + Version) (*AddrWithKeyInfo, error) + // SetAddrManaged sets an address as being managed by the internal // wallet. SetAddrManaged(ctx context.Context, addr *AddrWithKeyInfo, @@ -603,6 +609,14 @@ func (b *Book) AddrByTaprootOutput(ctx context.Context, return b.cfg.Store.AddrByTaprootOutput(ctx, key) } +// AddrByScriptKeyAndVersion returns a single address based on its script key +// and version or a sql.ErrNoRows error if no such address exists. +func (b *Book) AddrByScriptKeyAndVersion(ctx context.Context, + scriptKey *btcec.PublicKey, version Version) (*AddrWithKeyInfo, error) { + + return b.cfg.Store.AddrByScriptKeyAndVersion(ctx, scriptKey, version) +} + // SetAddrManaged sets an address as being managed by the internal // wallet. func (b *Book) SetAddrManaged(ctx context.Context, addr *AddrWithKeyInfo, diff --git a/address/book_test.go b/address/book_test.go index 51e1d93e1..f68c95876 100644 --- a/address/book_test.go +++ b/address/book_test.go @@ -175,6 +175,13 @@ type MockStorage struct { mock.Mock } +func (m *MockStorage) AddrByScriptKeyAndVersion(ctx context.Context, + key *btcec.PublicKey, version Version) (*AddrWithKeyInfo, error) { + + args := m.Called(ctx, key, version) + return args.Get(0).(*AddrWithKeyInfo), args.Error(1) +} + func (m *MockStorage) GetOrCreateEvent(ctx context.Context, status Status, addr *AddrWithKeyInfo, walletTx *lndclient.Transaction, outputIdx uint32) (*Event, error) { diff --git a/tapdb/addrs.go b/tapdb/addrs.go index f43f13dfe..026656edf 100644 --- a/tapdb/addrs.go +++ b/tapdb/addrs.go @@ -38,9 +38,15 @@ type ( // information. Addresses = sqlc.FetchAddrsRow - // AddrByTaprootOutput is a type alias for returning an address by its - // Taproot output key. - AddrByTaprootOutput = sqlc.FetchAddrByTaprootOutputKeyRow + // SingleAddrQuery is a type alias for returning an address by its + // Taproot output key, x-only script key or version (or a combination of + // those). + SingleAddrQuery = sqlc.QueryAddrParams + + // SingleAddrRow is a type alias for returning an address by either its + // Taproot output key, x-only script key or version (or a combination + // of those). + SingleAddrRow = sqlc.QueryAddrRow // AddrManaged is a type alias for setting an address as managed. AddrManaged = sqlc.SetAddrManagedParams @@ -111,11 +117,11 @@ type AddrBook interface { // passed AddrQuery. FetchAddrs(ctx context.Context, arg AddrQuery) ([]Addresses, error) - // FetchAddrByTaprootOutputKey returns a single address based on its - // Taproot output key or a sql.ErrNoRows error if no such address - // exists. - FetchAddrByTaprootOutputKey(ctx context.Context, - arg []byte) (AddrByTaprootOutput, error) + // QueryAddr returns a single address based on its Taproot output key, + // its x-only script key or version, or a sql.ErrNoRows error if no such + // address exists. + QueryAddr(ctx context.Context, arg SingleAddrQuery) (SingleAddrRow, + error) // UpsertAddr upserts a new address into the database returning the // primary key. @@ -509,8 +515,21 @@ func (t *TapAddressBook) AddrByTaprootOutput(ctx context.Context, readOpts = NewAddrBookReadTx() ) err := t.db.ExecTx(ctx, &readOpts, func(db AddrBook) error { - var err error - addr, err = fetchAddr(ctx, db, t.params, key) + row, err := db.QueryAddr(ctx, SingleAddrQuery{ + TaprootOutputKey: schnorr.SerializePubKey(key), + }) + switch { + case errors.Is(err, sql.ErrNoRows): + return address.ErrNoAddr + + case err != nil: + return err + } + + addr, err = parseAddr( + ctx, db, t.params, row.Addr, row.ScriptKey, + row.InternalKey, row.InternalKey_2, + ) return err }) if err != nil { @@ -520,22 +539,48 @@ func (t *TapAddressBook) AddrByTaprootOutput(ctx context.Context, return addr, nil } -// fetchAddr fetches a single address identified by its taproot output key from -// the database and populates all its fields. -func fetchAddr(ctx context.Context, db AddrBook, params *address.ChainParams, - taprootOutputKey *btcec.PublicKey) (*address.AddrWithKeyInfo, error) { +// AddrByScriptKeyAndVersion returns a single address based on its script key +// and version or a sql.ErrNoRows error if no such address exists. +func (t *TapAddressBook) AddrByScriptKeyAndVersion(ctx context.Context, + scriptKey *btcec.PublicKey, + version address.Version) (*address.AddrWithKeyInfo, error) { - dbAddr, err := db.FetchAddrByTaprootOutputKey( - ctx, schnorr.SerializePubKey(taprootOutputKey), + var ( + addr *address.AddrWithKeyInfo + readOpts = NewAddrBookReadTx() ) - switch { - case errors.Is(err, sql.ErrNoRows): - return nil, address.ErrNoAddr + err := t.db.ExecTx(ctx, &readOpts, func(db AddrBook) error { + row, err := db.QueryAddr(ctx, SingleAddrQuery{ + XOnlyScriptKey: schnorr.SerializePubKey(scriptKey), + Version: sqlInt16(version), + }) + switch { + case errors.Is(err, sql.ErrNoRows): + return address.ErrNoAddr - case err != nil: + case err != nil: + return err + } + + addr, err = parseAddr( + ctx, db, t.params, row.Addr, row.ScriptKey, + row.InternalKey, row.InternalKey_2, + ) + return err + }) + if err != nil { return nil, err } + return addr, nil +} + +// fetchAddr fetches a single address identified by its taproot output key from +// the database and populates all its fields. +func parseAddr(ctx context.Context, db AddrBook, params *address.ChainParams, + dbAddr sqlc.Addr, dbScriptKey sqlc.ScriptKey, dbInternalKey, + dbTaprootKey sqlc.InternalKey) (*address.AddrWithKeyInfo, error) { + genesis, err := fetchGenesis(ctx, db, dbAddr.GenesisAssetID) if err != nil { return nil, fmt.Errorf("error fetching genesis: %w", err) @@ -566,21 +611,21 @@ func fetchAddr(ctx context.Context, db AddrBook, params *address.ChainParams, } } - scriptKey, err := parseScriptKey(dbAddr.InternalKey, dbAddr.ScriptKey) + scriptKey, err := parseScriptKey(dbInternalKey, dbScriptKey) if err != nil { return nil, fmt.Errorf("unable to decode script key: %w", err) } - internalKey, err := btcec.ParsePubKey(dbAddr.RawTaprootKey) + internalKey, err := btcec.ParsePubKey(dbTaprootKey.RawKey) if err != nil { return nil, fmt.Errorf("unable to decode taproot key: %w", err) } internalKeyDesc := keychain.KeyDescriptor{ KeyLocator: keychain.KeyLocator{ Family: keychain.KeyFamily( - dbAddr.TaprootKeyFamily, + dbTaprootKey.KeyFamily, ), - Index: uint32(dbAddr.TaprootKeyIndex), + Index: uint32(dbTaprootKey.KeyIndex), }, PubKey: internalKey, } @@ -612,6 +657,12 @@ func fetchAddr(ctx context.Context, db AddrBook, params *address.ChainParams, return nil, fmt.Errorf("unable to make addr: %w", err) } + taprootOutputKey, err := tapAddr.TaprootOutputKey() + if err != nil { + return nil, fmt.Errorf("unable to get taproot output key: %w", + err) + } + return &address.AddrWithKeyInfo{ Tap: tapAddr, ScriptKeyTweak: *scriptKey.TweakedScriptKey, @@ -866,11 +917,25 @@ func (t *TapAddressBook) QueryAddrEvents( "output key: %w", err) } - addr, err := fetchAddr( - ctx, db, t.params, taprootOutputKey, + row, err := db.QueryAddr(ctx, SingleAddrQuery{ + TaprootOutputKey: schnorr.SerializePubKey( + taprootOutputKey, + ), + }) + switch { + case errors.Is(err, sql.ErrNoRows): + return address.ErrNoAddr + + case err != nil: + return err + } + + addr, err := parseAddr( + ctx, db, t.params, row.Addr, row.ScriptKey, + row.InternalKey, row.InternalKey_2, ) if err != nil { - return fmt.Errorf("error fetching address: %w", + return fmt.Errorf("error parsing address: %w", err) } diff --git a/tapdb/addrs_test.go b/tapdb/addrs_test.go index 1a1fdbf62..9acea2be2 100644 --- a/tapdb/addrs_test.go +++ b/tapdb/addrs_test.go @@ -785,3 +785,87 @@ func TestScriptKeyTypeUpsert(t *testing.T) { ) }) } + +// TestQueryAddrEvents tests that we can query address events by their taproot +// output key. +func TestQueryAddrEvents(t *testing.T) { + t.Parallel() + + // First, make a new addr book instance we'll use in the test below. + testClock := clock.NewTestClock(time.Now()) + addrBook, _ := newAddrBook(t, testClock) + + ctx := context.Background() + + // Insert a test address and event into the database. + proofCourierAddr := address.RandProofCourierAddr(t) + addr, assetGen, assetGroup := address.RandAddr( + t, chainParams, proofCourierAddr, + ) + err := addrBook.db.ExecTx( + ctx, WriteTxOption(), + insertFullAssetGen(ctx, assetGen, assetGroup), + ) + require.NoError(t, err) + + err = addrBook.InsertAddrs(ctx, *addr) + require.NoError(t, err) + + tx := randWalletTx() + event, err := addrBook.GetOrCreateEvent( + ctx, address.StatusTransactionDetected, addr, tx, 0, + ) + require.NoError(t, err) + + // Query events for the address. + queryParams := address.EventQueryParams{ + AddrTaprootOutputKey: schnorr.SerializePubKey( + &addr.TaprootOutputKey, + ), + } + events, err := addrBook.QueryAddrEvents(ctx, queryParams) + require.NoError(t, err) + require.Len(t, events, 1) + + // Verify the returned event matches the inserted event. + require.Equal(t, event.ID, events[0].ID) + require.Equal(t, event.Status, events[0].Status) + require.Equal(t, event.Outpoint, events[0].Outpoint) +} + +// TestAddrByScriptKeyAndVersion tests that we can retrieve an address by its +// script key and version. +func TestAddrByScriptKeyAndVersion(t *testing.T) { + t.Parallel() + + // First, make a new addr book instance we'll use in the test below. + testClock := clock.NewTestClock(time.Now()) + addrBook, _ := newAddrBook(t, testClock) + + ctx := context.Background() + + // Insert a test address into the database. + proofCourierAddr := address.RandProofCourierAddr(t) + addr, assetGen, assetGroup := address.RandAddr( + t, chainParams, proofCourierAddr, + ) + err := addrBook.db.ExecTx( + ctx, WriteTxOption(), + insertFullAssetGen(ctx, assetGen, assetGroup), + ) + require.NoError(t, err) + + err = addrBook.InsertAddrs(ctx, *addr) + require.NoError(t, err) + + // Query the address by script key and version. + result, err := addrBook.AddrByScriptKeyAndVersion( + ctx, &addr.ScriptKey, addr.Version, + ) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify the returned address matches the inserted address. + require.Equal(t, addr.ScriptKey, result.ScriptKey) + require.Equal(t, addr.Version, result.Version) +} diff --git a/tapdb/sqlc/addrs.sql.go b/tapdb/sqlc/addrs.sql.go index 75b395afd..5c385739c 100644 --- a/tapdb/sqlc/addrs.sql.go +++ b/tapdb/sqlc/addrs.sql.go @@ -11,76 +11,6 @@ import ( "time" ) -const FetchAddrByTaprootOutputKey = `-- name: FetchAddrByTaprootOutputKey :one -SELECT - version, asset_version, genesis_asset_id, group_key, tapscript_sibling, - taproot_output_key, amount, asset_type, creation_time, managed_from, - proof_courier_addr, - script_keys.script_key_id, script_keys.internal_key_id, script_keys.tweaked_script_key, script_keys.tweak, script_keys.key_type, - raw_script_keys.key_id, raw_script_keys.raw_key, raw_script_keys.key_family, raw_script_keys.key_index, - taproot_keys.raw_key AS raw_taproot_key, - taproot_keys.key_family AS taproot_key_family, - taproot_keys.key_index AS taproot_key_index -FROM addrs -JOIN script_keys - ON addrs.script_key_id = script_keys.script_key_id -JOIN internal_keys raw_script_keys - ON script_keys.internal_key_id = raw_script_keys.key_id -JOIN internal_keys taproot_keys - ON addrs.taproot_key_id = taproot_keys.key_id -WHERE taproot_output_key = $1 -` - -type FetchAddrByTaprootOutputKeyRow struct { - Version int16 - AssetVersion int16 - GenesisAssetID int64 - GroupKey []byte - TapscriptSibling []byte - TaprootOutputKey []byte - Amount int64 - AssetType int16 - CreationTime time.Time - ManagedFrom sql.NullTime - ProofCourierAddr []byte - ScriptKey ScriptKey - InternalKey InternalKey - RawTaprootKey []byte - TaprootKeyFamily int32 - TaprootKeyIndex int32 -} - -func (q *Queries) FetchAddrByTaprootOutputKey(ctx context.Context, taprootOutputKey []byte) (FetchAddrByTaprootOutputKeyRow, error) { - row := q.db.QueryRowContext(ctx, FetchAddrByTaprootOutputKey, taprootOutputKey) - var i FetchAddrByTaprootOutputKeyRow - err := row.Scan( - &i.Version, - &i.AssetVersion, - &i.GenesisAssetID, - &i.GroupKey, - &i.TapscriptSibling, - &i.TaprootOutputKey, - &i.Amount, - &i.AssetType, - &i.CreationTime, - &i.ManagedFrom, - &i.ProofCourierAddr, - &i.ScriptKey.ScriptKeyID, - &i.ScriptKey.InternalKeyID, - &i.ScriptKey.TweakedScriptKey, - &i.ScriptKey.Tweak, - &i.ScriptKey.KeyType, - &i.InternalKey.KeyID, - &i.InternalKey.RawKey, - &i.InternalKey.KeyFamily, - &i.InternalKey.KeyIndex, - &i.RawTaprootKey, - &i.TaprootKeyFamily, - &i.TaprootKeyIndex, - ) - return i, err -} - const FetchAddrEvent = `-- name: FetchAddrEvent :one SELECT creation_time, status, asset_proof_id, asset_id, @@ -302,6 +232,76 @@ func (q *Queries) FetchAddrs(ctx context.Context, arg FetchAddrsParams) ([]Fetch return items, nil } +const QueryAddr = `-- name: QueryAddr :one +SELECT + addrs.id, addrs.version, addrs.asset_version, addrs.genesis_asset_id, addrs.group_key, addrs.script_key_id, addrs.taproot_key_id, addrs.tapscript_sibling, addrs.taproot_output_key, addrs.amount, addrs.asset_type, addrs.creation_time, addrs.managed_from, addrs.proof_courier_addr, + script_keys.script_key_id, script_keys.internal_key_id, script_keys.tweaked_script_key, script_keys.tweak, script_keys.key_type, + raw_script_keys.key_id, raw_script_keys.raw_key, raw_script_keys.key_family, raw_script_keys.key_index, + taproot_keys.key_id, taproot_keys.raw_key, taproot_keys.key_family, taproot_keys.key_index +FROM addrs +JOIN script_keys + ON addrs.script_key_id = script_keys.script_key_id +JOIN internal_keys raw_script_keys + ON script_keys.internal_key_id = raw_script_keys.key_id +JOIN internal_keys taproot_keys + ON addrs.taproot_key_id = taproot_keys.key_id +WHERE + (addrs.taproot_output_key = $1 OR + $1 IS NULL) + AND (addrs.version = $2 OR + $2 IS NULL) + AND (substr(script_keys.tweaked_script_key, 2) = $3 OR + $3 IS NULL) +` + +type QueryAddrParams struct { + TaprootOutputKey []byte + Version sql.NullInt16 + XOnlyScriptKey []byte +} + +type QueryAddrRow struct { + Addr Addr + ScriptKey ScriptKey + InternalKey InternalKey + InternalKey_2 InternalKey +} + +func (q *Queries) QueryAddr(ctx context.Context, arg QueryAddrParams) (QueryAddrRow, error) { + row := q.db.QueryRowContext(ctx, QueryAddr, arg.TaprootOutputKey, arg.Version, arg.XOnlyScriptKey) + var i QueryAddrRow + err := row.Scan( + &i.Addr.ID, + &i.Addr.Version, + &i.Addr.AssetVersion, + &i.Addr.GenesisAssetID, + &i.Addr.GroupKey, + &i.Addr.ScriptKeyID, + &i.Addr.TaprootKeyID, + &i.Addr.TapscriptSibling, + &i.Addr.TaprootOutputKey, + &i.Addr.Amount, + &i.Addr.AssetType, + &i.Addr.CreationTime, + &i.Addr.ManagedFrom, + &i.Addr.ProofCourierAddr, + &i.ScriptKey.ScriptKeyID, + &i.ScriptKey.InternalKeyID, + &i.ScriptKey.TweakedScriptKey, + &i.ScriptKey.Tweak, + &i.ScriptKey.KeyType, + &i.InternalKey.KeyID, + &i.InternalKey.RawKey, + &i.InternalKey.KeyFamily, + &i.InternalKey.KeyIndex, + &i.InternalKey_2.KeyID, + &i.InternalKey_2.RawKey, + &i.InternalKey_2.KeyFamily, + &i.InternalKey_2.KeyIndex, + ) + return i, err +} + const QueryEventIDs = `-- name: QueryEventIDs :many SELECT addr_events.id as event_id, addrs.taproot_output_key as taproot_output_key diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 8f4eefc27..47ca240ac 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -48,7 +48,6 @@ type Querier interface { DeleteUniverseSupplyLeaf(ctx context.Context, arg DeleteUniverseSupplyLeafParams) error DeleteUniverseSupplyLeaves(ctx context.Context, namespaceRoot string) error DeleteUniverseSupplyRoot(ctx context.Context, namespaceRoot string) error - FetchAddrByTaprootOutputKey(ctx context.Context, taprootOutputKey []byte) (FetchAddrByTaprootOutputKeyRow, error) FetchAddrEvent(ctx context.Context, id int64) (FetchAddrEventRow, error) FetchAddrEventByAddrKeyAndOutpoint(ctx context.Context, arg FetchAddrEventByAddrKeyAndOutpointParams) (FetchAddrEventByAddrKeyAndOutpointRow, error) FetchAddrs(ctx context.Context, arg FetchAddrsParams) ([]FetchAddrsRow, error) @@ -136,6 +135,7 @@ type Querier interface { LogProofTransferAttempt(ctx context.Context, arg LogProofTransferAttemptParams) error LogServerSync(ctx context.Context, arg LogServerSyncParams) error NewMintingBatch(ctx context.Context, arg NewMintingBatchParams) error + QueryAddr(ctx context.Context, arg QueryAddrParams) (QueryAddrRow, error) // We use a LEFT JOIN here as not every asset has a group key, so this'll // generate rows that have NULL values for the group key fields if an asset // doesn't have a group key. See the comment in fetchAssetSprouts for a work diff --git a/tapdb/sqlc/queries/addrs.sql b/tapdb/sqlc/queries/addrs.sql index ec16ee7f7..60a759b34 100644 --- a/tapdb/sqlc/queries/addrs.sql +++ b/tapdb/sqlc/queries/addrs.sql @@ -65,16 +65,12 @@ WHERE creation_time >= @created_after ORDER BY addrs.creation_time LIMIT @num_limit OFFSET @num_offset; --- name: FetchAddrByTaprootOutputKey :one +-- name: QueryAddr :one SELECT - version, asset_version, genesis_asset_id, group_key, tapscript_sibling, - taproot_output_key, amount, asset_type, creation_time, managed_from, - proof_courier_addr, + sqlc.embed(addrs), sqlc.embed(script_keys), sqlc.embed(raw_script_keys), - taproot_keys.raw_key AS raw_taproot_key, - taproot_keys.key_family AS taproot_key_family, - taproot_keys.key_index AS taproot_key_index + sqlc.embed(taproot_keys) FROM addrs JOIN script_keys ON addrs.script_key_id = script_keys.script_key_id @@ -82,7 +78,13 @@ JOIN internal_keys raw_script_keys ON script_keys.internal_key_id = raw_script_keys.key_id JOIN internal_keys taproot_keys ON addrs.taproot_key_id = taproot_keys.key_id -WHERE taproot_output_key = $1; +WHERE + (addrs.taproot_output_key = sqlc.narg('taproot_output_key') OR + sqlc.narg('taproot_output_key') IS NULL) + AND (addrs.version = sqlc.narg('version') OR + sqlc.narg('version') IS NULL) + AND (substr(script_keys.tweaked_script_key, 2) = sqlc.narg('x_only_script_key') OR + sqlc.narg('x_only_script_key') IS NULL); -- name: SetAddrManaged :exec WITH target_addr(addr_id) AS ( From 62b78417a059f74ddc51b553edcbf5d5af7168d4 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Wed, 25 Jun 2025 17:50:15 +0200 Subject: [PATCH 2/6] scripts+tapdb: update sqlc to v1.29.0 --- scripts/gen_sqlc_docker.sh | 2 +- tapdb/sqlc/addrs.sql.go | 2 +- tapdb/sqlc/assets.sql.go | 2 +- tapdb/sqlc/authmailbox.sql.go | 2 +- tapdb/sqlc/db.go | 2 +- tapdb/sqlc/macaroons.sql.go | 2 +- tapdb/sqlc/metadata.sql.go | 2 +- tapdb/sqlc/models.go | 2 +- tapdb/sqlc/mssmt.sql.go | 2 +- tapdb/sqlc/querier.go | 2 +- tapdb/sqlc/supply_commit.sql.go | 2 +- tapdb/sqlc/supply_tree.sql.go | 2 +- tapdb/sqlc/transfers.sql.go | 2 +- tapdb/sqlc/universe.sql.go | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/scripts/gen_sqlc_docker.sh b/scripts/gen_sqlc_docker.sh index 9eb22fadf..b02fbb0b7 100755 --- a/scripts/gen_sqlc_docker.sh +++ b/scripts/gen_sqlc_docker.sh @@ -45,4 +45,4 @@ docker run \ -e UID=$UID \ -v "$DIR/../:/build" \ -w /build \ - sqlc/sqlc:1.28.0 generate + sqlc/sqlc:1.29.0 generate diff --git a/tapdb/sqlc/addrs.sql.go b/tapdb/sqlc/addrs.sql.go index 5c385739c..a0d8436c4 100644 --- a/tapdb/sqlc/addrs.sql.go +++ b/tapdb/sqlc/addrs.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: addrs.sql package sqlc diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index 31d57a70e..e2a1f61a4 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: assets.sql package sqlc diff --git a/tapdb/sqlc/authmailbox.sql.go b/tapdb/sqlc/authmailbox.sql.go index 86dd6ef81..bf96c87b0 100644 --- a/tapdb/sqlc/authmailbox.sql.go +++ b/tapdb/sqlc/authmailbox.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: authmailbox.sql package sqlc diff --git a/tapdb/sqlc/db.go b/tapdb/sqlc/db.go index 8277a70db..e4d78283b 100644 --- a/tapdb/sqlc/db.go +++ b/tapdb/sqlc/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 package sqlc diff --git a/tapdb/sqlc/macaroons.sql.go b/tapdb/sqlc/macaroons.sql.go index 0c4930de1..69fc09aff 100644 --- a/tapdb/sqlc/macaroons.sql.go +++ b/tapdb/sqlc/macaroons.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: macaroons.sql package sqlc diff --git a/tapdb/sqlc/metadata.sql.go b/tapdb/sqlc/metadata.sql.go index 32788764a..77c71547c 100644 --- a/tapdb/sqlc/metadata.sql.go +++ b/tapdb/sqlc/metadata.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: metadata.sql package sqlc diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 477071c7c..8d83b522e 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 package sqlc diff --git a/tapdb/sqlc/mssmt.sql.go b/tapdb/sqlc/mssmt.sql.go index 3a793ba1c..b3bc54391 100644 --- a/tapdb/sqlc/mssmt.sql.go +++ b/tapdb/sqlc/mssmt.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: mssmt.sql package sqlc diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 47ca240ac..b605f72c6 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 package sqlc diff --git a/tapdb/sqlc/supply_commit.sql.go b/tapdb/sqlc/supply_commit.sql.go index c9d5b0170..7d7ba890d 100644 --- a/tapdb/sqlc/supply_commit.sql.go +++ b/tapdb/sqlc/supply_commit.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: supply_commit.sql package sqlc diff --git a/tapdb/sqlc/supply_tree.sql.go b/tapdb/sqlc/supply_tree.sql.go index f0126aa15..74d9521a9 100644 --- a/tapdb/sqlc/supply_tree.sql.go +++ b/tapdb/sqlc/supply_tree.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: supply_tree.sql package sqlc diff --git a/tapdb/sqlc/transfers.sql.go b/tapdb/sqlc/transfers.sql.go index 2ec3dec3d..4d86865b4 100644 --- a/tapdb/sqlc/transfers.sql.go +++ b/tapdb/sqlc/transfers.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: transfers.sql package sqlc diff --git a/tapdb/sqlc/universe.sql.go b/tapdb/sqlc/universe.sql.go index d2b42310a..73b516b0c 100644 --- a/tapdb/sqlc/universe.sql.go +++ b/tapdb/sqlc/universe.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: universe.sql package sqlc From 90ef28a1099cd24cd392dca1ece51e140bb2670f Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Wed, 25 Jun 2025 18:52:19 +0200 Subject: [PATCH 3/6] asset+scripts+tapd: allow querying multiple script key types This introduces a workaround that gets us WHERE xxx IN (...) queries working with a little trick. See the comment in scripts/gen_sqlc_docker.sh for more information on how this works and why the workaround is needed. --- asset/asset.go | 55 +++++++++++++++ scripts/gen_sqlc_docker.sh | 25 ++++++- tapdb/assets_store.go | 71 +++++++++---------- tapdb/sqlc/assets.sql.go | 127 ++++++++++++++++++++-------------- tapdb/sqlc/db_custom.go | 22 ++++++ tapdb/sqlc/queries/assets.sql | 24 ++++--- 6 files changed, 225 insertions(+), 99 deletions(-) diff --git a/asset/asset.go b/asset/asset.go index 5222d92be..90c2e23e6 100644 --- a/asset/asset.go +++ b/asset/asset.go @@ -152,6 +152,61 @@ const ( ScriptKeyScriptPathChannel ScriptKeyType = 5 ) +var ( + // AllScriptKeyTypes is a slice of all known script key types. + AllScriptKeyTypes = []ScriptKeyType{ + ScriptKeyUnknown, + ScriptKeyBip86, + ScriptKeyScriptPathExternal, + ScriptKeyBurn, + ScriptKeyTombstone, + ScriptKeyScriptPathChannel, + } + + // ScriptKeyTypesNoChannel is a slice of all known script key types + // that are not related to channels. This is used to filter out channel + // related script keys when querying for assets that are not related to + // channels. + ScriptKeyTypesNoChannel = []ScriptKeyType{ + ScriptKeyUnknown, + ScriptKeyBip86, + ScriptKeyScriptPathExternal, + ScriptKeyBurn, + ScriptKeyTombstone, + } +) + +// ScriptKeyTypeForDatabaseQuery returns a slice of script key types that should +// be used when querying the database for assets. The returned slice will either +// contain all script key types or only those that are not related to channels, +// depending on the `filterChannelRelated` parameter. Unless the user specifies +// a specific script key type, in which case the returned slice will only +// contain that specific script key type. +func ScriptKeyTypeForDatabaseQuery(filterChannelRelated bool, + userSpecified fn.Option[ScriptKeyType]) []ScriptKeyType { + + // For some queries, we want to get all the assets with all possible + // script key types. For those, we use the full set of script key types. + dbTypes := fn.CopySlice(AllScriptKeyTypes) + + // For some RPCs (mostly balance related), we exclude the assets that + // are specifically used for funding custom channels by default. The + // balance of those assets is reported through lnd channel balance. + // Those assets are identified by the specific script key type for + // channel keys. We exclude them unless explicitly queried for. + if filterChannelRelated { + dbTypes = fn.CopySlice(ScriptKeyTypesNoChannel) + } + + // If the user specified a script key type, we use that to filter the + // results. + userSpecified.WhenSome(func(t ScriptKeyType) { + dbTypes = []ScriptKeyType{t} + }) + + return dbTypes +} + var ( // ZeroPrevID is the blank prev ID used for genesis assets and also // asset split leaves. diff --git a/scripts/gen_sqlc_docker.sh b/scripts/gen_sqlc_docker.sh index b02fbb0b7..ddc6e9d36 100755 --- a/scripts/gen_sqlc_docker.sh +++ b/scripts/gen_sqlc_docker.sh @@ -16,7 +16,8 @@ trap restore_files EXIT # Directory of the script file, independent of where it's called from. DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Use the user's cache directories + +# Use the user's cache directories. GOCACHE=$(go env GOCACHE) GOMODCACHE=$(go env GOMODCACHE) @@ -46,3 +47,25 @@ docker run \ -v "$DIR/../:/build" \ -w /build \ sqlc/sqlc:1.29.0 generate + +# Because we're using the Postgres dialect of sqlc, we can't use sqlc.slice() +# normally, because sqlc just thinks it can pass the Golang slice directly to +# the database driver. So it doesn't put the /*SLICE:*/ workaround +# comment into the actual SQL query. But we add the comment ourselves and now +# just need to replace the '$X/*SLICE:*/' placeholders with the +# actual placeholder that's going to be replaced by the sqlc generated code. +echo "Applying sqlc.slice() workaround..." +for file in tapdb/sqlc/*.sql.go; do + echo "Patching $file" + + # First, we replace the `$X/*SLICE:*/` placeholders with + # the actual placeholder that sqlc will use: `/*SLICE:*/?`. + sed -i.bak -E 's/\$([0-9]+)\/\*SLICE:([a-zA-Z_][a-zA-Z0-9_]*)\*\//\/\*SLICE:\2\*\/\?/g' "$file" + + # Then, we replace the `strings.Repeat(",?", len(arg.))[1:]` with + # a function call that generates the correct number of placeholders: + # `makeQueryParams(len(queryParams), len(arg.))`. + sed -i.bak -E 's/strings\.Repeat\(",\?", len\(([^)]+)\)\)\[1:\]/makeQueryParams(len(queryParams), len(\1))/g' "$file" + + rm "$file.bak" +done diff --git a/tapdb/assets_store.go b/tapdb/assets_store.go index d4f8a791f..7d52cb353 100644 --- a/tapdb/assets_store.go +++ b/tapdb/assets_store.go @@ -895,16 +895,34 @@ func (a *AssetStore) constraintsToDbFilter( assetFilter.AssetIDFilter = nil } - // The fn.None option means we don't restrict on script key type - // at all. - query.ScriptKeyType.WhenSome(func(t asset.ScriptKeyType) { - assetFilter.ScriptKeyType = sqlInt16(t) - }) + // Let's figure out the set of script key types we want to + // query for. If the user specified a script key type, then we + // use that to filter the results. If the user didn't specify a + // script key type, then we use the full set of script key + // types, as this might be an internal query that also needs to + // know channel related assets. + assetFilter.ScriptKeyType = scriptKeyTypesForQuery( + false, query.ScriptKeyType, + ) } return assetFilter, nil } +// scriptKeyTypesForQuery returns the set of script key types we use in the +// SQL queries based on the passed parameters. If the user specified a script +// key type, then we use that to filter the results. If the user didn't +// specify a script key type, then we use the full set of script key types +// or the set of script key types that excludes channel related assets, +// depending on the filterChannelRelated parameter. +func scriptKeyTypesForQuery(filterChannelRelated bool, + userSpecified fn.Option[asset.ScriptKeyType]) []sql.NullInt16 { + + return fn.Map(asset.ScriptKeyTypeForDatabaseQuery( + filterChannelRelated, userSpecified, + ), sqlInt16) +} + // specificAssetFilter maps the given asset parameters to the set of filters // we use in the SQL queries. func (a *AssetStore) specificAssetFilter(id asset.ID, anchorPoint wire.OutPoint, @@ -945,6 +963,17 @@ func fetchAssetsWithWitness(ctx context.Context, q ActiveAssetsStore, assetFilter QueryAssetFilters) ([]ConfirmedAsset, assetWitnesses, error) { + // We're using a slice of types to query for the set of script key + // types, which is turned into a `xxx IN (...)` SQL query. But that + // doesn't work for empty slices, as that would result in + // `xxx IN (NULL)` which evaluates to false. So we need to use all + // available types instead. + if len(assetFilter.ScriptKeyType) == 0 { + assetFilter.ScriptKeyType = fn.Map( + asset.AllScriptKeyTypes, sqlInt16, + ) + } + // First, we'll fetch all the assets we know of on disk. dbAssets, err := q.QueryAssets(ctx, assetFilter) if err != nil { @@ -1002,21 +1031,7 @@ func (a *AssetStore) QueryBalancesByAsset(ctx context.Context, // channels. The balance of those assets is reported through lnd channel // balance. Those assets are identified by the specific script key type // for channel keys. We exclude them unless explicitly queried for. - assetBalancesFilter.ExcludeScriptKeyType = sqlInt16( - asset.ScriptKeyScriptPathChannel, - ) - - // The fn.None option means we don't restrict on script key type at all. - skt.WhenSome(func(t asset.ScriptKeyType) { - assetBalancesFilter.ScriptKeyType = sqlInt16(t) - - // If the user explicitly wants to see the channel related asset - // balances, we need to set the exclude type to NULL. - if t == asset.ScriptKeyScriptPathChannel { - nullValue := sql.NullInt16{} - assetBalancesFilter.ExcludeScriptKeyType = nullValue - } - }) + assetBalancesFilter.ScriptKeyType = scriptKeyTypesForQuery(true, skt) // By default, we only show assets that are not leased. if !includeLeased { @@ -1094,21 +1109,7 @@ func (a *AssetStore) QueryAssetBalancesByGroup(ctx context.Context, // channels. The balance of those assets is reported through lnd channel // balance. Those assets are identified by the specific script key type // for channel keys. We exclude them unless explicitly queried for. - assetBalancesFilter.ExcludeScriptKeyType = sqlInt16( - asset.ScriptKeyScriptPathChannel, - ) - - // The fn.None option means we don't restrict on script key type at all. - skt.WhenSome(func(t asset.ScriptKeyType) { - assetBalancesFilter.ScriptKeyType = sqlInt16(t) - - // If the user explicitly wants to see the channel related asset - // balances, we need to set the exclude type to NULL. - if t == asset.ScriptKeyScriptPathChannel { - nullValue := sql.NullInt16{} - assetBalancesFilter.ExcludeScriptKeyType = nullValue - } - }) + assetBalancesFilter.ScriptKeyType = scriptKeyTypesForQuery(true, skt) // By default, we only show assets that are not leased. if !includeLeased { diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index e2a1f61a4..e011a6d90 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -8,6 +8,7 @@ package sqlc import ( "context" "database/sql" + "strings" "time" ) @@ -2298,11 +2299,11 @@ JOIN managed_utxos utxos END JOIN script_keys ON assets.script_key_id = script_keys.script_key_id -WHERE spent = FALSE AND - (script_keys.key_type != $4 OR - $4 IS NULL) AND - ($5 = script_keys.key_type OR - $5 IS NULL) +WHERE spent = FALSE AND + -- The script_key_type argument must NEVER be an empty slice, otherwise this + -- query will return no results. + COALESCE(script_keys.key_type, 0) IN + (/*SLICE:script_key_type*/?) GROUP BY assets.genesis_id, genesis_info_view.asset_id, genesis_info_view.asset_tag, genesis_info_view.meta_hash, genesis_info_view.asset_type, genesis_info_view.output_index, @@ -2310,11 +2311,10 @@ GROUP BY assets.genesis_id, genesis_info_view.asset_id, ` type QueryAssetBalancesByAssetParams struct { - AssetIDFilter []byte - Leased interface{} - Now sql.NullTime - ExcludeScriptKeyType sql.NullInt16 - ScriptKeyType sql.NullInt16 + AssetIDFilter []byte + Leased interface{} + Now sql.NullTime + ScriptKeyType []sql.NullInt16 } type QueryAssetBalancesByAssetRow struct { @@ -2332,13 +2332,20 @@ type QueryAssetBalancesByAssetRow struct { // doesn't have a group key. See the comment in fetchAssetSprouts for a work // around that needs to be used with this query until a sqlc bug is fixed. func (q *Queries) QueryAssetBalancesByAsset(ctx context.Context, arg QueryAssetBalancesByAssetParams) ([]QueryAssetBalancesByAssetRow, error) { - rows, err := q.db.QueryContext(ctx, QueryAssetBalancesByAsset, - arg.AssetIDFilter, - arg.Leased, - arg.Now, - arg.ExcludeScriptKeyType, - arg.ScriptKeyType, - ) + query := QueryAssetBalancesByAsset + var queryParams []interface{} + queryParams = append(queryParams, arg.AssetIDFilter) + queryParams = append(queryParams, arg.Leased) + queryParams = append(queryParams, arg.Now) + if len(arg.ScriptKeyType) > 0 { + for _, v := range arg.ScriptKeyType { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:script_key_type*/?", makeQueryParams(len(queryParams), len(arg.ScriptKeyType)), 1) + } else { + query = strings.Replace(query, "/*SLICE:script_key_type*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } @@ -2390,19 +2397,18 @@ JOIN managed_utxos utxos JOIN script_keys ON assets.script_key_id = script_keys.script_key_id WHERE spent = FALSE AND - (script_keys.key_type != $4 OR - $4 IS NULL) AND - ($5 = script_keys.key_type OR - $5 IS NULL) + -- The script_key_type argument must NEVER be an empty slice, otherwise this + -- query will return no results. + COALESCE(script_keys.key_type, 0) IN + (/*SLICE:script_key_type*/?) GROUP BY key_group_info_view.tweaked_group_key ` type QueryAssetBalancesByGroupParams struct { - KeyGroupFilter []byte - Leased interface{} - Now sql.NullTime - ExcludeScriptKeyType sql.NullInt16 - ScriptKeyType sql.NullInt16 + KeyGroupFilter []byte + Leased interface{} + Now sql.NullTime + ScriptKeyType []sql.NullInt16 } type QueryAssetBalancesByGroupRow struct { @@ -2411,13 +2417,20 @@ type QueryAssetBalancesByGroupRow struct { } func (q *Queries) QueryAssetBalancesByGroup(ctx context.Context, arg QueryAssetBalancesByGroupParams) ([]QueryAssetBalancesByGroupRow, error) { - rows, err := q.db.QueryContext(ctx, QueryAssetBalancesByGroup, - arg.KeyGroupFilter, - arg.Leased, - arg.Now, - arg.ExcludeScriptKeyType, - arg.ScriptKeyType, - ) + query := QueryAssetBalancesByGroup + var queryParams []interface{} + queryParams = append(queryParams, arg.KeyGroupFilter) + queryParams = append(queryParams, arg.Leased) + queryParams = append(queryParams, arg.Now) + if len(arg.ScriptKeyType) > 0 { + for _, v := range arg.ScriptKeyType { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:script_key_type*/?", makeQueryParams(len(queryParams), len(arg.ScriptKeyType)), 1) + } else { + query = strings.Replace(query, "/*SLICE:script_key_type*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } @@ -2511,8 +2524,10 @@ WHERE ( assets.anchor_utxo_id = COALESCE($11, assets.anchor_utxo_id) AND assets.genesis_id = COALESCE($12, assets.genesis_id) AND assets.script_key_id = COALESCE($13, assets.script_key_id) AND - ($14 = script_keys.key_type OR - $14 IS NULL) + -- The script_key_type argument must NEVER be an empty slice, otherwise this + -- query will return no results. + COALESCE(script_keys.key_type, 0) IN + (/*SLICE:script_key_type*/?) ) ` @@ -2530,7 +2545,7 @@ type QueryAssetsParams struct { AnchorUtxoID sql.NullInt64 GenesisID sql.NullInt64 ScriptKeyID sql.NullInt64 - ScriptKeyType sql.NullInt16 + ScriptKeyType []sql.NullInt16 } type QueryAssetsRow struct { @@ -2581,22 +2596,30 @@ type QueryAssetsRow struct { // make the entire statement evaluate to true, if none of these extra args are // specified. func (q *Queries) QueryAssets(ctx context.Context, arg QueryAssetsParams) ([]QueryAssetsRow, error) { - rows, err := q.db.QueryContext(ctx, QueryAssets, - arg.AssetIDFilter, - arg.TweakedScriptKey, - arg.AnchorPoint, - arg.Leased, - arg.Now, - arg.MinAnchorHeight, - arg.MinAmt, - arg.MaxAmt, - arg.Spent, - arg.KeyGroupFilter, - arg.AnchorUtxoID, - arg.GenesisID, - arg.ScriptKeyID, - arg.ScriptKeyType, - ) + query := QueryAssets + var queryParams []interface{} + queryParams = append(queryParams, arg.AssetIDFilter) + queryParams = append(queryParams, arg.TweakedScriptKey) + queryParams = append(queryParams, arg.AnchorPoint) + queryParams = append(queryParams, arg.Leased) + queryParams = append(queryParams, arg.Now) + queryParams = append(queryParams, arg.MinAnchorHeight) + queryParams = append(queryParams, arg.MinAmt) + queryParams = append(queryParams, arg.MaxAmt) + queryParams = append(queryParams, arg.Spent) + queryParams = append(queryParams, arg.KeyGroupFilter) + queryParams = append(queryParams, arg.AnchorUtxoID) + queryParams = append(queryParams, arg.GenesisID) + queryParams = append(queryParams, arg.ScriptKeyID) + if len(arg.ScriptKeyType) > 0 { + for _, v := range arg.ScriptKeyType { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:script_key_type*/?", makeQueryParams(len(queryParams), len(arg.ScriptKeyType)), 1) + } else { + query = strings.Replace(query, "/*SLICE:script_key_type*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } diff --git a/tapdb/sqlc/db_custom.go b/tapdb/sqlc/db_custom.go index f9e70033b..7d4cc95c2 100644 --- a/tapdb/sqlc/db_custom.go +++ b/tapdb/sqlc/db_custom.go @@ -1,5 +1,9 @@ package sqlc +import ( + "fmt" +) + // BackendType is an enum that represents the type of database backend we're // using. type BackendType uint8 @@ -44,3 +48,21 @@ func NewSqlite(db DBTX) *Queries { func NewPostgres(db DBTX) *Queries { return &Queries{db: &wrappedTX{db, BackendTypePostgres}} } + +// makeQueryParams generates a string of query parameters for a SQL query. It is +// meant to replace the `?` placeholders in a SQL query with numbered parameters +// like `$1`, `$2`, etc. This is required for the sqlc /*SLICE:*/ +// workaround. See scripts/gen_sqlc_docker.sh for more details. +func makeQueryParams(numTotalArgs, numListArgs int) string { + diff := numTotalArgs - numListArgs + result := "" + for i := diff + 1; i <= numTotalArgs; i++ { + if i == numTotalArgs { + result += fmt.Sprintf("$%d", i) + + continue + } + result += fmt.Sprintf("$%d,", i) + } + return result +} diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index 924553025..650e6fb72 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -348,11 +348,11 @@ JOIN managed_utxos utxos END JOIN script_keys ON assets.script_key_id = script_keys.script_key_id -WHERE spent = FALSE AND - (script_keys.key_type != sqlc.narg('exclude_script_key_type') OR - sqlc.narg('exclude_script_key_type') IS NULL) AND - (sqlc.narg('script_key_type') = script_keys.key_type OR - sqlc.narg('script_key_type') IS NULL) +WHERE spent = FALSE AND + -- The script_key_type argument must NEVER be an empty slice, otherwise this + -- query will return no results. + COALESCE(script_keys.key_type, 0) IN + (sqlc.slice('script_key_type')/*SLICE:script_key_type*/) GROUP BY assets.genesis_id, genesis_info_view.asset_id, genesis_info_view.asset_tag, genesis_info_view.meta_hash, genesis_info_view.asset_type, genesis_info_view.output_index, @@ -380,10 +380,10 @@ JOIN managed_utxos utxos JOIN script_keys ON assets.script_key_id = script_keys.script_key_id WHERE spent = FALSE AND - (script_keys.key_type != sqlc.narg('exclude_script_key_type') OR - sqlc.narg('exclude_script_key_type') IS NULL) AND - (sqlc.narg('script_key_type') = script_keys.key_type OR - sqlc.narg('script_key_type') IS NULL) + -- The script_key_type argument must NEVER be an empty slice, otherwise this + -- query will return no results. + COALESCE(script_keys.key_type, 0) IN + (sqlc.slice('script_key_type')/*SLICE:script_key_type*/) GROUP BY key_group_info_view.tweaked_group_key; -- name: FetchGroupedAssets :many @@ -516,8 +516,10 @@ WHERE ( assets.anchor_utxo_id = COALESCE(sqlc.narg('anchor_utxo_id'), assets.anchor_utxo_id) AND assets.genesis_id = COALESCE(sqlc.narg('genesis_id'), assets.genesis_id) AND assets.script_key_id = COALESCE(sqlc.narg('script_key_id'), assets.script_key_id) AND - (sqlc.narg('script_key_type') = script_keys.key_type OR - sqlc.narg('script_key_type') IS NULL) + -- The script_key_type argument must NEVER be an empty slice, otherwise this + -- query will return no results. + COALESCE(script_keys.key_type, 0) IN + (sqlc.slice('script_key_type')/*SLICE:script_key_type*/) ); -- name: AllAssets :many From 6e03ce4a4627cb3c2650860ce0bd0cb376c7f181 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 23 Jun 2025 16:23:30 +0200 Subject: [PATCH 4/6] asset: add new script key type for pedersen unique keys --- address/book.go | 2 +- asset/asset.go | 127 ++++++++++++-- itest/script_key_type_test.go | 161 ++++++++++++++++++ itest/test_list_on_test.go | 4 + rpcserver.go | 2 +- rpcutils/marshal.go | 6 + tapdb/post_migration_checks.go | 2 +- .../assetwalletrpc/assetwallet.swagger.json | 5 +- taprpc/mintrpc/mint.swagger.json | 5 +- taprpc/taprootassets.pb.go | 16 +- taprpc/taprootassets.proto | 11 ++ taprpc/taprootassets.swagger.json | 25 +-- taprpc/universerpc/universe.swagger.json | 5 +- 13 files changed, 337 insertions(+), 34 deletions(-) create mode 100644 itest/script_key_type_test.go diff --git a/address/book.go b/address/book.go index 0fe2dee1c..7f54d7444 100644 --- a/address/book.go +++ b/address/book.go @@ -510,7 +510,7 @@ func (b *Book) NewAddressWithKeys(ctx context.Context, addrVersion Version, // We might not know the type of script key, if it was given to us // through an RPC call. So we make a guess here. - keyType := scriptKey.DetermineType() + keyType := scriptKey.DetermineType(fn.Ptr(assetGroup.Genesis.ID())) err = b.cfg.Store.InsertScriptKey(ctx, scriptKey, keyType) if err != nil { diff --git a/asset/asset.go b/asset/asset.go index 90c2e23e6..6322dbcfd 100644 --- a/asset/asset.go +++ b/asset/asset.go @@ -150,6 +150,13 @@ const ( // Keys related to channels are not shown in asset balances (unless // specifically requested) and are _never_ used for coin selection. ScriptKeyScriptPathChannel ScriptKeyType = 5 + + // ScriptKeyUniquePedersen is the script key type used for assets that + // use a unique script key, tweaked with a Pedersen commitment key in a + // single Tapscript leaf. This is used to avoid collisions in the + // universe when there are multiple grouped asset UTXOs within the same + // on-chain output. + ScriptKeyUniquePedersen ScriptKeyType = 6 ) var ( @@ -161,6 +168,7 @@ var ( ScriptKeyBurn, ScriptKeyTombstone, ScriptKeyScriptPathChannel, + ScriptKeyUniquePedersen, } // ScriptKeyTypesNoChannel is a slice of all known script key types @@ -173,18 +181,38 @@ var ( ScriptKeyScriptPathExternal, ScriptKeyBurn, ScriptKeyTombstone, + ScriptKeyUniquePedersen, } ) // ScriptKeyTypeForDatabaseQuery returns a slice of script key types that should // be used when querying the database for assets. The returned slice will either // contain all script key types or only those that are not related to channels, -// depending on the `filterChannelRelated` parameter. Unless the user specifies +// depending on the `excludeChannelRelated` parameter. Unless the user specifies // a specific script key type, in which case the returned slice will only // contain that specific script key type. -func ScriptKeyTypeForDatabaseQuery(filterChannelRelated bool, +func ScriptKeyTypeForDatabaseQuery(excludeChannelRelated bool, userSpecified fn.Option[ScriptKeyType]) []ScriptKeyType { + // If the user specified a script key type, we use that directly to + // filter the results. + if userSpecified.IsSome() { + specifiedType := userSpecified.UnwrapOr(ScriptKeyUnknown) + dbTypes := []ScriptKeyType{ + specifiedType, + } + + // If the user specifically requested BIP-86 script keys, we + // also include the Pedersen unique script key type, because + // those can be spent the same way as BIP-86 script keys, and + // they should be treated the same way as BIP-86 script keys. + if specifiedType == ScriptKeyBip86 { + dbTypes = append(dbTypes, ScriptKeyUniquePedersen) + } + + return dbTypes + } + // For some queries, we want to get all the assets with all possible // script key types. For those, we use the full set of script key types. dbTypes := fn.CopySlice(AllScriptKeyTypes) @@ -194,16 +222,10 @@ func ScriptKeyTypeForDatabaseQuery(filterChannelRelated bool, // balance of those assets is reported through lnd channel balance. // Those assets are identified by the specific script key type for // channel keys. We exclude them unless explicitly queried for. - if filterChannelRelated { + if excludeChannelRelated { dbTypes = fn.CopySlice(ScriptKeyTypesNoChannel) } - // If the user specified a script key type, we use that to filter the - // results. - userSpecified.WhenSome(func(t ScriptKeyType) { - dbTypes = []ScriptKeyType{t} - }) - return dbTypes } @@ -447,7 +469,7 @@ func NewSpecifierFromGroupKey(groupPubKey btcec.PublicKey) Specifier { } } -// NewExlusiveSpecifier creates a specifier that may only include one of asset +// NewExclusiveSpecifier creates a specifier that may only include one of asset // ID or group key. If both are set then a specifier over the group key is // created. func NewExclusiveSpecifier(id *ID, @@ -1098,6 +1120,65 @@ func EqualKeyDescriptors(a, o keychain.KeyDescriptor) bool { return a.PubKey.IsEqual(o.PubKey) } +// ScriptKeyDerivationMethod is the method used to derive the script key of an +// asset send output from the recipient's internal key and the asset ID of +// the output. This is used to ensure that the script keys are unique for each +// asset ID, so that proofs can be fetched from the universe without collisions. +type ScriptKeyDerivationMethod uint8 + +const ( + // ScriptKeyDerivationUniquePedersen means the script key is derived + // using the address's recipient ID key and a single leaf that contains + // an un-spendable Pedersen commitment key + // (OP_CHECKSIG ). This can be used to + // create unique script keys for each virtual packet in the fragment, + // to avoid proof collisions in the universe, where the script keys + // should be spendable by a hardware wallet that only supports + // miniscript policies for signing P2TR outputs. + ScriptKeyDerivationUniquePedersen ScriptKeyDerivationMethod = 0 +) + +// DeriveUniqueScriptKey derives a unique script key for the given asset ID +// using the recipient's internal key and the specified derivation method. +func DeriveUniqueScriptKey(internalKey btcec.PublicKey, assetID ID, + method ScriptKeyDerivationMethod) (ScriptKey, error) { + + switch method { + // For the unique Pedersen method, we derive the script key using the + // internal key and the asset ID using a Pedersen commitment key in a + // single OP_CHECKSIG leaf. + case ScriptKeyDerivationUniquePedersen: + leaf, err := NewNonSpendableScriptLeaf( + PedersenVersion, assetID[:], + ) + if err != nil { + return ScriptKey{}, fmt.Errorf("unable to create "+ + "non-spendable leaf: %w", err) + } + + rootHash := leaf.TapHash() + scriptPubKey, _ := schnorr.ParsePubKey(schnorr.SerializePubKey( + txscript.ComputeTaprootOutputKey( + &internalKey, rootHash[:], + ), + )) + return ScriptKey{ + PubKey: scriptPubKey, + TweakedScriptKey: &TweakedScriptKey{ + RawKey: keychain.KeyDescriptor{ + PubKey: &internalKey, + }, + Tweak: rootHash[:], + Type: ScriptKeyUniquePedersen, + }, + }, nil + + default: + return ScriptKey{}, fmt.Errorf("unknown script key derivation "+ + "method: %d", method) + } +} + // TweakedScriptKey is an embedded struct which is primarily used by wallets to // be able to keep track of the tweak of a script key alongside the raw key // derivation information. @@ -1197,14 +1278,16 @@ func (s *ScriptKey) HasScriptPath() bool { } // DetermineType attempts to determine the type of the script key based on the -// information available. This method will only return ScriptKeyUnknown if the -// following condition is met: +// information available. This method will only return ScriptKeyUnknown if one +// of the following conditions is met: // - The script key doesn't have a script path, but the final Taproot output // key doesn't match a BIP-0086 key derived from the internal key. This will // be the case for "foreign" script keys we import from proofs, where we set // the internal key to the same key as the tweaked script key (because we // don't know the internal key, as it's not part of the proof encoding). -func (s *ScriptKey) DetermineType() ScriptKeyType { +// - No asset ID was provided (because it is unavailable in the given +// context), and the script key is a unique Pedersen-based key. +func (s *ScriptKey) DetermineType(id *ID) ScriptKeyType { // If we have an explicit script key type set, we can return that. if s.TweakedScriptKey != nil && s.TweakedScriptKey.Type != ScriptKeyUnknown { @@ -1233,6 +1316,24 @@ func (s *ScriptKey) DetermineType() ScriptKeyType { if bip86.PubKey.IsEqual(s.PubKey) { return ScriptKeyBip86 } + + // If we have the asset's ID, we can check whether this is a + // Pedersen-based key. If we don't have the ID, then we can't + // determine the type, so we'll end up in the default return + // below. + if id != nil { + scriptKey, err := DeriveUniqueScriptKey( + *s.TweakedScriptKey.RawKey.PubKey, *id, + ScriptKeyDerivationUniquePedersen, + ) + if err != nil { + return ScriptKeyUnknown + } + + if scriptKey.PubKey.IsEqual(s.PubKey) { + return ScriptKeyUniquePedersen + } + } } return ScriptKeyUnknown diff --git a/itest/script_key_type_test.go b/itest/script_key_type_test.go new file mode 100644 index 000000000..dd44ff22e --- /dev/null +++ b/itest/script_key_type_test.go @@ -0,0 +1,161 @@ +package itest + +import ( + "context" + "testing" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/rpcutils" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/taprpc" + wrpc "github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + "github.com/stretchr/testify/require" +) + +// testScriptKeyTypePedersenUnique tests that we can declare a script key with +// the Pedersen unique tweak type, which is used for assets that are sent using +// the future address V2 scheme. +func testScriptKeyTypePedersenUnique(t *harnessTest) { + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout) + defer cancel() + + rpcAssets := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, t.tapd, + []*mintrpc.MintAssetRequest{ + simpleAssets[0], + // Our "passive" asset. + { + Asset: &mintrpc.MintAsset{ + AssetType: taprpc.AssetType_NORMAL, + Name: "itestbuxx-passive", + AssetMeta: &taprpc.AssetMeta{ + Data: []byte("some metadata"), + }, + Amount: 123, + }, + }, + }, + ) + activeAsset := rpcAssets[0] + passiveAsset := rpcAssets[1] + + var ( + activeID, passiveID asset.ID + ) + copy(activeID[:], activeAsset.AssetGenesis.AssetId) + copy(passiveID[:], passiveAsset.AssetGenesis.AssetId) + + // We need to derive two sets of keys, one for the new script key and + // one for the internal key each. + activeScriptKey, activeAnchorIntKeyDesc1 := DeriveKeys(t.t, t.tapd) + activeScriptKey = declarePedersenUniqueScriptKey( + t.t, t.tapd, activeScriptKey, activeID, + ) + passiveScriptKey, _ := DeriveKeys(t.t, t.tapd) + passiveScriptKey = declarePedersenUniqueScriptKey( + t.t, t.tapd, passiveScriptKey, passiveID, + ) + + // We create the output at anchor index 0 for the first address. + outputAmounts := []uint64{300, 4700, 123} + vPkt := tappsbt.ForInteractiveSend( + activeID, outputAmounts[1], activeScriptKey, 0, 0, 0, + activeAnchorIntKeyDesc1, asset.V0, chainParams, + ) + + // We now fund the packet, so we get the passive assets as well. + fundResp := fundPacket(t, t.tapd, vPkt) + require.Len(t.t, fundResp.PassiveAssetPsbts, 1) + + // We now replace the script key of the passive packet with the Pedersen + // key that we declared above, then sign the packet. + passiveAssetPkt, err := tappsbt.Decode(fundResp.PassiveAssetPsbts[0]) + require.NoError(t.t, err) + require.Len(t.t, passiveAssetPkt.Outputs, 1) + + passiveAssetPkt.Outputs[0].ScriptKey = passiveScriptKey + if passiveAssetPkt.Outputs[0].Asset != nil { + passiveAssetPkt.Outputs[0].Asset.ScriptKey = passiveScriptKey + } + passiveAssetPkt = signVirtualPacket(t.t, t.tapd, passiveAssetPkt) + + // We now also sign the active asset packet. + activeAssetPkt, err := tappsbt.Decode(fundResp.FundedPsbt) + require.NoError(t.t, err) + + activeAssetPkt = signVirtualPacket(t.t, t.tapd, activeAssetPkt) + + activeBytes, err := tappsbt.Encode(activeAssetPkt) + require.NoError(t.t, err) + passiveBytes, err := tappsbt.Encode(passiveAssetPkt) + require.NoError(t.t, err) + + // Now we'll attempt to complete the transfer. + sendResp, err := t.tapd.AnchorVirtualPsbts( + ctxt, &wrpc.AnchorVirtualPsbtsRequest{ + VirtualPsbts: [][]byte{ + activeBytes, + passiveBytes, + }, + }, + ) + require.NoError(t.t, err) + + ConfirmAndAssertOutboundTransferWithOutputs( + t.t, t.lndHarness.Miner().Client, t.tapd, sendResp, activeID[:], + outputAmounts, 0, 1, 3, + ) + + AssertBalances( + t.t, t.tapd, 4700, WithAssetID(activeID[:]), WithNumUtxos(1), + WithScriptKeyType(asset.ScriptKeyUniquePedersen), + ) + AssertBalances( + t.t, t.tapd, 5000, WithAssetID(activeID[:]), WithNumUtxos(2), + WithScriptKeyType(asset.ScriptKeyBip86), + ) + AssertBalances( + t.t, t.tapd, 123, WithAssetID(passiveID[:]), WithNumUtxos(1), + WithScriptKeyType(asset.ScriptKeyUniquePedersen), + ) + + aliceAssets, err := t.tapd.ListAssets(ctxb, &taprpc.ListAssetRequest{}) + require.NoError(t.t, err) + + assetsJSON, err := formatProtoJSON(aliceAssets) + require.NoError(t.t, err) + t.Logf("Got assets: %s", assetsJSON) + + // We should now be able to spend all the outputs, the Pedersen keys + // should be signed correctly both in the active and passive assets. + sendAssetAndAssert( + ctxt, t, t.tapd, t.tapd, 4900, 100, activeAsset.AssetGenesis, + activeAsset, 1, 2, 1, + ) +} + +func declarePedersenUniqueScriptKey(t *testing.T, node tapClient, + sk asset.ScriptKey, assetID asset.ID) asset.ScriptKey { + + pedersenKey, err := asset.DeriveUniqueScriptKey( + *sk.RawKey.PubKey, assetID, + asset.ScriptKeyDerivationUniquePedersen, + ) + require.NoError(t, err) + + // We need to let the wallet of Bob know that we're going to use a + // script key with a custom root. + ctxt, cancel := context.WithTimeout( + context.Background(), defaultTimeout, + ) + defer cancel() + + _, err = node.DeclareScriptKey(ctxt, &wrpc.DeclareScriptKeyRequest{ + ScriptKey: rpcutils.MarshalScriptKey(pedersenKey), + }) + require.NoError(t, err) + + return pedersenKey +} diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index 0eb53c7a3..207eac387 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -351,6 +351,10 @@ var testCases = []*testCase{ name: "auth mailbox message store and fetch", test: testAuthMailboxStoreAndFetchMessage, }, + { + name: "script key type pedersen unique", + test: testScriptKeyTypePedersenUnique, + }, } var optionalTestCases = []*testCase{ diff --git a/rpcserver.go b/rpcserver.go index 95e714940..fd8b91c8c 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -8508,7 +8508,7 @@ func (r *rpcServer) DeclareScriptKey(ctx context.Context, // 100% sure of the type, if it wasn't declared. But we can make a // best-effort guess based on the fields the user has set. This is a // no-op if the type is already set. - scriptKey.Type = scriptKey.DetermineType() + scriptKey.Type = scriptKey.DetermineType(nil) // The user is declaring the key, so they should know what type it is. // So if they didn't set it, and it wasn't an obvious one, we'll require diff --git a/rpcutils/marshal.go b/rpcutils/marshal.go index b50d53ebc..1dbeaf33b 100644 --- a/rpcutils/marshal.go +++ b/rpcutils/marshal.go @@ -160,6 +160,9 @@ func UnmarshalScriptKeyType(rpcType taprpc.ScriptKeyType) (asset.ScriptKeyType, case taprpc.ScriptKeyType_SCRIPT_KEY_CHANNEL: return asset.ScriptKeyScriptPathChannel, nil + case taprpc.ScriptKeyType_SCRIPT_KEY_UNIQUE_PEDERSEN: + return asset.ScriptKeyUniquePedersen, nil + default: return 0, fmt.Errorf("unknown script key type: %v", rpcType) } @@ -183,6 +186,9 @@ func MarshalScriptKeyType(typ asset.ScriptKeyType) taprpc.ScriptKeyType { case asset.ScriptKeyScriptPathChannel: return taprpc.ScriptKeyType_SCRIPT_KEY_CHANNEL + case asset.ScriptKeyUniquePedersen: + return taprpc.ScriptKeyType_SCRIPT_KEY_UNIQUE_PEDERSEN + default: return taprpc.ScriptKeyType_SCRIPT_KEY_UNKNOWN } diff --git a/tapdb/post_migration_checks.go b/tapdb/post_migration_checks.go index 44a2ddf33..2b8c9b0b2 100644 --- a/tapdb/post_migration_checks.go +++ b/tapdb/post_migration_checks.go @@ -166,7 +166,7 @@ func determineAndAssignScriptKeyType(ctx context.Context, if _, ok := burnKeys[serializedKey]; ok { newType = asset.ScriptKeyBurn } else { - assumedType := scriptKey.DetermineType() + assumedType := scriptKey.DetermineType(nil) switch { // If we're sure that a key is BIP-86 or the well-known diff --git a/taprpc/assetwalletrpc/assetwallet.swagger.json b/taprpc/assetwalletrpc/assetwallet.swagger.json index 36f04ef95..56adde2d2 100644 --- a/taprpc/assetwalletrpc/assetwallet.swagger.json +++ b/taprpc/assetwalletrpc/assetwallet.swagger.json @@ -1079,10 +1079,11 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN", - "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection." + "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs." }, "taprpcSendAssetResponse": { "type": "object", diff --git a/taprpc/mintrpc/mint.swagger.json b/taprpc/mintrpc/mint.swagger.json index 8eeb733eb..649c7c497 100644 --- a/taprpc/mintrpc/mint.swagger.json +++ b/taprpc/mintrpc/mint.swagger.json @@ -914,10 +914,11 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN", - "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection." + "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs." }, "taprpcTapBranch": { "type": "object", diff --git a/taprpc/taprootassets.pb.go b/taprpc/taprootassets.pb.go index 592752ad8..0183911b2 100644 --- a/taprpc/taprootassets.pb.go +++ b/taprpc/taprootassets.pb.go @@ -364,6 +364,14 @@ const ( // balances (unless specifically requested) and are never used for coin // selection. ScriptKeyType_SCRIPT_KEY_CHANNEL ScriptKeyType = 5 + // The script key is derived using the asset ID and a single leaf that contains + // an un-spendable Pedersen commitment key + // `(OP_CHECKSIG )`. This can be used to create + // unique script keys for each virtual packet in the fragment, to avoid proof + // collisions in the universe, where the script keys should be spendable by + // a hardware wallet that only supports miniscript policies for signing P2TR + // outputs. + ScriptKeyType_SCRIPT_KEY_UNIQUE_PEDERSEN ScriptKeyType = 6 ) // Enum value maps for ScriptKeyType. @@ -375,6 +383,7 @@ var ( 3: "SCRIPT_KEY_BURN", 4: "SCRIPT_KEY_TOMBSTONE", 5: "SCRIPT_KEY_CHANNEL", + 6: "SCRIPT_KEY_UNIQUE_PEDERSEN", } ScriptKeyType_value = map[string]int32{ "SCRIPT_KEY_UNKNOWN": 0, @@ -383,6 +392,7 @@ var ( "SCRIPT_KEY_BURN": 3, "SCRIPT_KEY_TOMBSTONE": 4, "SCRIPT_KEY_CHANNEL": 5, + "SCRIPT_KEY_UNIQUE_PEDERSEN": 6, } ) @@ -7738,7 +7748,7 @@ var file_taprootassets_proto_rawDesc = []byte{ 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x41, 0x44, 0x44, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, 0x4f, 0x4e, 0x5f, 0x56, 0x30, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x41, 0x44, 0x44, 0x52, 0x5f, 0x56, 0x45, 0x52, 0x53, 0x49, - 0x4f, 0x4e, 0x5f, 0x56, 0x31, 0x10, 0x02, 0x2a, 0xa9, 0x01, 0x0a, 0x0d, 0x53, 0x63, 0x72, 0x69, + 0x4f, 0x4e, 0x5f, 0x56, 0x31, 0x10, 0x02, 0x2a, 0xc9, 0x01, 0x0a, 0x0d, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x4b, 0x65, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x14, 0x0a, 0x10, 0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x5f, 0x4b, 0x45, 0x59, 0x5f, @@ -7749,7 +7759,9 @@ var file_taprootassets_proto_rawDesc = []byte{ 0x03, 0x12, 0x18, 0x0a, 0x14, 0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x54, 0x4f, 0x4d, 0x42, 0x53, 0x54, 0x4f, 0x4e, 0x45, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x5f, 0x4b, 0x45, 0x59, 0x5f, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, - 0x4c, 0x10, 0x05, 0x2a, 0xd0, 0x01, 0x0a, 0x0f, 0x41, 0x64, 0x64, 0x72, 0x45, 0x76, 0x65, 0x6e, + 0x4c, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x5f, 0x4b, 0x45, + 0x59, 0x5f, 0x55, 0x4e, 0x49, 0x51, 0x55, 0x45, 0x5f, 0x50, 0x45, 0x44, 0x45, 0x52, 0x53, 0x45, + 0x4e, 0x10, 0x06, 0x2a, 0xd0, 0x01, 0x0a, 0x0f, 0x41, 0x64, 0x64, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1d, 0x0a, 0x19, 0x41, 0x44, 0x44, 0x52, 0x5f, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x2a, 0x0a, 0x26, 0x41, 0x44, 0x44, 0x52, 0x5f, 0x45, diff --git a/taprpc/taprootassets.proto b/taprpc/taprootassets.proto index cb4a7a057..05ab39241 100644 --- a/taprpc/taprootassets.proto +++ b/taprpc/taprootassets.proto @@ -1087,6 +1087,17 @@ enum ScriptKeyType { selection. */ SCRIPT_KEY_CHANNEL = 5; + + /* + The script key is derived using the asset ID and a single leaf that contains + an un-spendable Pedersen commitment key + `(OP_CHECKSIG )`. This can be used to create + unique script keys for each virtual packet in the fragment, to avoid proof + collisions in the universe, where the script keys should be spendable by + a hardware wallet that only supports miniscript policies for signing P2TR + outputs. + */ + SCRIPT_KEY_UNIQUE_PEDERSEN = 6; } message ScriptKeyTypeQuery { diff --git a/taprpc/taprootassets.swagger.json b/taprpc/taprootassets.swagger.json index 0df2bf2c3..40374345e 100644 --- a/taprpc/taprootassets.swagger.json +++ b/taprpc/taprootassets.swagger.json @@ -280,7 +280,7 @@ }, { "name": "script_key.type", - "description": "The type of the script key. This type is either user-declared when custom\nscript keys are added, or automatically determined by the daemon for\nstandard operations (e.g. BIP-86 keys, burn keys, tombstone keys, channel\nrelated keys).\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.", + "description": "The type of the script key. This type is either user-declared when custom\nscript keys are added, or automatically determined by the daemon for\nstandard operations (e.g. BIP-86 keys, burn keys, tombstone keys, channel\nrelated keys).\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs.", "in": "query", "required": false, "type": "string", @@ -290,7 +290,8 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN" }, @@ -312,7 +313,7 @@ }, { "name": "script_key_type.explicit_type", - "description": "Query for assets of a specific script key type.\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.", + "description": "Query for assets of a specific script key type.\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs.", "in": "query", "required": false, "type": "string", @@ -322,7 +323,8 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN" }, @@ -397,7 +399,7 @@ }, { "name": "script_key_type.explicit_type", - "description": "Query for assets of a specific script key type.\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.", + "description": "Query for assets of a specific script key type.\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs.", "in": "query", "required": false, "type": "string", @@ -407,7 +409,8 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN" }, @@ -681,7 +684,7 @@ }, { "name": "script_key_type.explicit_type", - "description": "Query for assets of a specific script key type.\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.", + "description": "Query for assets of a specific script key type.\n\n - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs.", "in": "query", "required": false, "type": "string", @@ -691,7 +694,8 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN" }, @@ -2471,10 +2475,11 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN", - "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection." + "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs." }, "taprpcScriptKeyTypeQuery": { "type": "object", diff --git a/taprpc/universerpc/universe.swagger.json b/taprpc/universerpc/universe.swagger.json index c79bfdf83..13a731d57 100644 --- a/taprpc/universerpc/universe.swagger.json +++ b/taprpc/universerpc/universe.swagger.json @@ -1880,10 +1880,11 @@ "SCRIPT_KEY_SCRIPT_PATH_EXTERNAL", "SCRIPT_KEY_BURN", "SCRIPT_KEY_TOMBSTONE", - "SCRIPT_KEY_CHANNEL" + "SCRIPT_KEY_CHANNEL", + "SCRIPT_KEY_UNIQUE_PEDERSEN" ], "default": "SCRIPT_KEY_UNKNOWN", - "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection." + "description": " - SCRIPT_KEY_UNKNOWN: The type of script key is not known. This should only be stored for assets\nwhere we don't know the internal key of the script key (e.g. for\nimported proofs).\n - SCRIPT_KEY_BIP86: The script key is a normal BIP-86 key. This means that the internal key is\nturned into a Taproot output key by applying a BIP-86 tweak to it.\n - SCRIPT_KEY_SCRIPT_PATH_EXTERNAL: The script key is a key that contains a script path that is defined by the\nuser and is therefore external to the tapd wallet. Spending this key\nrequires providing a specific witness and must be signed through the vPSBT\nsigning flow.\n - SCRIPT_KEY_BURN: The script key is a specific un-spendable key that indicates a burnt asset.\nAssets with this key type can never be spent again, as a burn key is a\ntweaked NUMS key that nobody knows the private key for.\n - SCRIPT_KEY_TOMBSTONE: The script key is a specific un-spendable key that indicates a tombstone\noutput. This is only the case for zero-value assets that result from a\nnon-interactive (TAP address) send where no change was left over.\n - SCRIPT_KEY_CHANNEL: The script key is used for an asset that resides within a Taproot Asset\nChannel. That means the script key is either a funding key (OP_TRUE), a\ncommitment output key (to_local, to_remote, htlc), or a HTLC second-level\ntransaction output key. Keys related to channels are not shown in asset\nbalances (unless specifically requested) and are never used for coin\nselection.\n - SCRIPT_KEY_UNIQUE_PEDERSEN: The script key is derived using the asset ID and a single leaf that contains\nan un-spendable Pedersen commitment key\n`(OP_CHECKSIG \u003cNUMS_key + asset_id * G\u003e)`. This can be used to create\nunique script keys for each virtual packet in the fragment, to avoid proof\ncollisions in the universe, where the script keys should be spendable by\na hardware wallet that only supports miniscript policies for signing P2TR\noutputs." }, "taprpcSplitCommitment": { "type": "object", From 704dbc981fa1f4e6b66cd7162c2f2001092c6743 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 23 Jun 2025 16:23:31 +0200 Subject: [PATCH 5/6] authmailbox: add multi subscriber helper type This MultiSubscription helper struct allows us to subscribe to receive messages for multiple keys held by a receiving wallet but all consolidated into a single message channel. --- authmailbox/client_test.go | 56 +++++++++- authmailbox/mock.go | 3 + authmailbox/multi_subscription.go | 166 ++++++++++++++++++++++++++++ authmailbox/receive_subscription.go | 20 +++- 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 authmailbox/multi_subscription.go diff --git a/authmailbox/client_test.go b/authmailbox/client_test.go index 9a4257966..0353d472d 100644 --- a/authmailbox/client_test.go +++ b/authmailbox/client_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "net/url" "os" "testing" "time" @@ -236,6 +237,40 @@ func TestServerClientAuthAndRestart(t *testing.T) { client2.stop(t) }) + // We also add a multi-subscription to the same two keys, so we can make + // sure we can receive messages from multiple clients at once. + multiSub := NewMultiSubscription(*clientCfg) + err := multiSub.Subscribe( + ctx, url.URL{Host: clientCfg.ServerAddress}, clientKey1, filter, + ) + require.NoError(t, err) + err = multiSub.Subscribe( + ctx, url.URL{Host: clientCfg.ServerAddress}, clientKey2, filter, + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, multiSub.Stop()) + }) + msgChan := multiSub.MessageChan() + readMultiSub := func(targetID ...uint64) { + t.Helper() + select { + case inboundMsgs := <-msgChan: + receivedIDs := fn.Map( + inboundMsgs.Messages, + func(msg *mboxrpc.MailboxMessage) uint64 { + return msg.MessageId + }, + ) + for _, target := range targetID { + require.Contains(t, receivedIDs, target) + } + case <-time.After(testTimeout): + t.Fatalf("timeout waiting for message with ID %v", + targetID) + } + } + // Send a message to all clients. msg1 := &Message{ ID: 1000, @@ -244,7 +279,7 @@ func TestServerClientAuthAndRestart(t *testing.T) { } // We also store the message in the store, so we can retrieve it later. - _, err := harness.mockMsgStore.StoreMessage(ctx, randOp, msg1) + _, err = harness.mockMsgStore.StoreMessage(ctx, randOp, msg1) require.NoError(t, err) harness.srv.publishMessage(msg1) @@ -252,6 +287,7 @@ func TestServerClientAuthAndRestart(t *testing.T) { // We should be able to receive that message. client1.readMessages(t, msg1.ID) client2.readMessages(t, msg1.ID) + readMultiSub(msg1.ID) // We now stop the server and assert that the subscription is no longer // active. @@ -282,6 +318,7 @@ func TestServerClientAuthAndRestart(t *testing.T) { // We should be able to receive that message. client1.readMessages(t, msg2.ID) client2.readMessages(t, msg2.ID) + readMultiSub(msg2.ID) // If we now start a third client, we should be able to receive all // three messages, given we are using the same key and specify the @@ -314,6 +351,23 @@ func TestServerClientAuthAndRestart(t *testing.T) { harness.srv.publishMessage(msg3) client4.expectNoMessage(t) client1.readMessages(t, msg3.ID) + client2.readMessages(t, msg3.ID) + client3.readMessages(t, msg3.ID) + readMultiSub(msg3.ID) + + // Let's make sure that a message sent to the second key is only + // received by the fourth client and the multi-subscription. + msg4 := &Message{ + ID: 1001, + ReceiverKey: *clientKey2.PubKey, + ArrivalTimestamp: time.Now(), + } + harness.srv.publishMessage(msg4) + client1.expectNoMessage(t) + client2.expectNoMessage(t) + client3.expectNoMessage(t) + client4.readMessages(t, msg4.ID) + readMultiSub(msg4.ID) } // TestSendMessage tests the SendMessage RPC of the server and its ability to diff --git a/authmailbox/mock.go b/authmailbox/mock.go index 158b6558c..76bc40a4c 100644 --- a/authmailbox/mock.go +++ b/authmailbox/mock.go @@ -87,5 +87,8 @@ func (s *MockMsgStore) QueryMessages(_ context.Context, } func (s *MockMsgStore) NumMessages(context.Context) uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return uint64(len(s.messages)) } diff --git a/authmailbox/multi_subscription.go b/authmailbox/multi_subscription.go new file mode 100644 index 000000000..e224ed2bd --- /dev/null +++ b/authmailbox/multi_subscription.go @@ -0,0 +1,166 @@ +package authmailbox + +import ( + "context" + "fmt" + "net/url" + "sync" + + "github.com/lightninglabs/taproot-assets/asset" + lfn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" +) + +// clientSubscriptions holds the subscriptions and cancel functions for a +// specific mailbox client. +type clientSubscriptions struct { + // client is the mailbox client that this subscription belongs to. + client *Client + + // subscriptions holds the active subscriptions for this client, keyed + // by the serialized public key of the receiver. + subscriptions map[asset.SerializedKey]ReceiveSubscription + + // cancels holds the cancel functions for each subscription, also keyed + // by the serialized public key of the receiver. + cancels map[asset.SerializedKey]context.CancelFunc +} + +// MultiSubscription is a subscription manager that can handle multiple mailbox +// clients, allowing subscriptions to different accounts across different +// mailbox servers. It manages subscriptions and message queues for each client +// and provides a unified interface for receiving messages. +type MultiSubscription struct { + // baseClientConfig holds the basic configuration for the mailbox + // clients. All fields except the ServerAddress are used to create + // new mailbox clients when needed. + baseClientConfig ClientConfig + + // clients holds the active mailbox clients, keyed by their server URL. + clients map[url.URL]*clientSubscriptions + + // msgQueue is the concurrent queue that holds received messages from + // all subscriptions across all clients. This allows for a unified + // message channel that can be used to receive messages from any + // subscribed account, regardless of which mailbox server it belongs to. + msgQueue *lfn.ConcurrentQueue[*ReceivedMessages] + + sync.RWMutex +} + +// NewMultiSubscription creates a new MultiSubscription instance. +func NewMultiSubscription(baseClientConfig ClientConfig) *MultiSubscription { + queue := lfn.NewConcurrentQueue[*ReceivedMessages](lfn.DefaultQueueSize) + queue.Start() + + return &MultiSubscription{ + baseClientConfig: baseClientConfig, + clients: make(map[url.URL]*clientSubscriptions), + msgQueue: queue, + } +} + +// Subscribe adds a new subscription for the specified client URL and receiver +// key. It starts a new mailbox client if one does not already exist for the +// given URL. The subscription will receive messages that match the provided +// filter and will send them to the shared message queue. +func (m *MultiSubscription) Subscribe(ctx context.Context, serverURL url.URL, + receiverKey keychain.KeyDescriptor, filter MessageFilter) error { + + // We hold the mutex for access to common resources. + m.Lock() + cfgCopy := m.baseClientConfig + client, ok := m.clients[serverURL] + + // If this is the first time we're seeing a server URL, we first create + // a network connection to the mailbox server. + if !ok { + cfgCopy.ServerAddress = serverURL.Host + + mboxClient := NewClient(&cfgCopy) + client = &clientSubscriptions{ + client: mboxClient, + subscriptions: make( + map[asset.SerializedKey]ReceiveSubscription, + ), + cancels: make( + map[asset.SerializedKey]context.CancelFunc, + ), + } + m.clients[serverURL] = client + + err := mboxClient.Start() + if err != nil { + m.Unlock() + return fmt.Errorf("unable to create mailbox client: %w", + err) + } + } + + // We release the lock here again, because StartAccountSubscription + // might block for a while, and we don't want to hold the lock + // unnecessarily long. + m.Unlock() + + ctx, cancel := context.WithCancel(ctx) + subscription, err := client.client.StartAccountSubscription( + ctx, m.msgQueue.ChanIn(), receiverKey, filter, + ) + if err != nil { + cancel() + return fmt.Errorf("unable to start mailbox subscription: %w", + err) + } + + // We hold the lock again to safely add the subscription and cancel + // function to the client's maps. + m.Lock() + key := asset.ToSerialized(receiverKey.PubKey) + client.subscriptions[key] = subscription + client.cancels[key] = cancel + m.Unlock() + + return nil +} + +// MessageChan returns a channel that can be used to receive messages from all +// subscriptions across all mailbox clients. This channel will receive +// ReceivedMessages, which contain the messages and their associated +// metadata, such as the sender and receiver keys. +func (m *MultiSubscription) MessageChan() <-chan *ReceivedMessages { + return m.msgQueue.ChanOut() +} + +// Stop stops all active subscriptions and mailbox clients. It cancels all +// active subscription contexts and waits for all clients to stop gracefully. +func (m *MultiSubscription) Stop() error { + defer m.msgQueue.Stop() + + log.Info("Stopping all mailbox clients and subscriptions...") + + m.RLock() + defer m.RUnlock() + + var lastErr error + for _, client := range m.clients { + for _, cancel := range client.cancels { + cancel() + } + + for _, sub := range client.subscriptions { + err := sub.Stop() + if err != nil { + log.Errorf("Error stopping subscription: %v", + err) + lastErr = err + } + } + + if err := client.client.Stop(); err != nil { + log.Errorf("Error stopping client: %v", err) + lastErr = err + } + } + + return lastErr +} diff --git a/authmailbox/receive_subscription.go b/authmailbox/receive_subscription.go index 4cdc043dd..42c990a2a 100644 --- a/authmailbox/receive_subscription.go +++ b/authmailbox/receive_subscription.go @@ -200,6 +200,18 @@ func (s *receiveSubscription) connectServerStream(ctx context.Context, err error ) for i := 0; i < numRetries; i++ { + // If we're shutting down, we don't want to re-try connecting. + select { + case <-s.quit: + log.DebugS(ctx, "Client is shutting down...") + return ErrClientShutdown + + case <-ctx.Done(): + log.DebugS(ctx, "Client is shutting down...") + return ErrClientShutdown + default: + } + // Wait before connecting in case this is a re-connect trial. if backoff != 0 { err = s.wait(backoff) @@ -441,16 +453,22 @@ func (s *receiveSubscription) HandleServerShutdown(ctx context.Context, // closeStream closes the long-lived stream connection to the server. func (s *receiveSubscription) closeStream(ctx context.Context) error { + log.InfoS(ctx, "Closing stream") + s.streamMutex.Lock() defer s.streamMutex.Unlock() + if s.streamCancel != nil { + s.streamCancel() + } + if s.serverStream == nil { + log.InfoS(ctx, "Server stream is not connected") return nil } log.DebugS(ctx, "Closing server stream") err := s.serverStream.CloseSend() - s.streamCancel() s.serverStream = nil return err From 7436356880aef9811af4b94422db28538b831c4d Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 23 Jun 2025 16:23:29 +0200 Subject: [PATCH 6/6] proof: add SendFragment and SendFragmentEnvelope types We need to place these types in the proof package because the proof courier will need access to them. And because we already use the proof package from the address package, we can't place the new types there, as that would create a circular package dependency. --- proof/encoding.go | 133 +++++++++++++++++++++++++++ proof/records.go | 83 +++++++++++++++++ proof/send.go | 218 +++++++++++++++++++++++++++++++++++++++++++++ proof/send_test.go | 91 +++++++++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 proof/send.go create mode 100644 proof/send_test.go diff --git a/proof/encoding.go b/proof/encoding.go index 1f94737c7..8930737a5 100644 --- a/proof/encoding.go +++ b/proof/encoding.go @@ -739,3 +739,136 @@ func PublicKeyOptionDecoder(r io.Reader, val any, buf *[8]byte, val, "*fn.Option[btcec.PublicKey]", l, l, ) } + +func FragmentVersionEncoder(w io.Writer, val any, buf *[8]byte) error { + if t, ok := val.(*SendFragmentVersion); ok { + return tlv.EUint8T(w, uint8(*t), buf) + } + return tlv.NewTypeForEncodingErr(val, "SendFragmentVersion") +} + +func FragmentVersionDecoder(r io.Reader, val any, buf *[8]byte, + l uint64) error { + + if typ, ok := val.(*SendFragmentVersion); ok { + var t uint8 + if err := tlv.DUint8(r, &t, buf, l); err != nil { + return err + } + *typ = SendFragmentVersion(t) + return nil + } + return tlv.NewTypeForDecodingErr(val, "SendFragmentVersion", l, 1) +} + +func SendOutputEncoder(w io.Writer, val any, buf *[8]byte) error { + if t, ok := val.(*SendOutput); ok { + err := tlv.EUint8T(w, uint8(t.AssetVersion), buf) + if err != nil { + return err + } + if err := tlv.EUint64T(w, t.Amount, buf); err != nil { + return err + } + err = tlv.EUint8T(w, uint8(t.DerivationMethod), buf) + if err != nil { + return err + } + + keyArr := ([btcec.PubKeyBytesLenCompressed]byte)(t.ScriptKey) + return tlv.EBytes33(w, &keyArr, buf) + } + return tlv.NewTypeForEncodingErr(val, "*SendOutput") +} + +func SendOutputDecoder(r io.Reader, val any, buf *[8]byte) error { + if typ, ok := val.(*SendOutput); ok { + var assetVersion uint8 + if err := tlv.DUint8(r, &assetVersion, buf, 1); err != nil { + return err + } + typ.AssetVersion = asset.Version(assetVersion) + + if err := tlv.DUint64(r, &typ.Amount, buf, 8); err != nil { + return err + } + + var derivationMethod uint8 + if err := tlv.DUint8(r, &derivationMethod, buf, 1); err != nil { + return err + } + typ.DerivationMethod = asset.ScriptKeyDerivationMethod( + derivationMethod, + ) + + keyArr := ([btcec.PubKeyBytesLenCompressed]byte)(typ.ScriptKey) + if err := tlv.DBytes33( + r, &keyArr, buf, btcec.PubKeyBytesLenCompressed, + ); err != nil { + return err + } + typ.ScriptKey = keyArr + + return nil + } + return tlv.NewTypeForEncodingErr(val, "*SendOutput") +} + +func SendOutputsEncoder(w io.Writer, val any, buf *[8]byte) error { + if t, ok := val.(*map[asset.ID]SendOutput); ok { + numOutputs := uint64(len(*t)) + if err := tlv.WriteVarInt(w, numOutputs, buf); err != nil { + return err + } + + for id, output := range *t { + idArr := ([32]byte)(id) + if err := tlv.EBytes32(w, &idArr, buf); err != nil { + return err + } + + err := SendOutputEncoder(w, &output, buf) + if err != nil { + return err + } + } + return nil + } + return tlv.NewTypeForEncodingErr(val, "map[asset.ID]SendOutput") +} + +func SendOutputsDecoder(r io.Reader, val any, buf *[8]byte, l uint64) error { + if typ, ok := val.(*map[asset.ID]SendOutput); ok { + numOutputs, err := tlv.ReadVarInt(r, buf) + if err != nil { + return err + } + + // Avoid OOM by limiting the number of send outputs we accept. + if numOutputs > MaxSendFragmentOutputs { + return fmt.Errorf("%w: too many send outputs", + ErrProofInvalid) + } + + result := make(map[asset.ID]SendOutput, numOutputs) + for i := uint64(0); i < numOutputs; i++ { + var id [32]byte + if err := tlv.DBytes32(r, &id, buf, 32); err != nil { + return err + } + + var output SendOutput + err := SendOutputDecoder(r, &output, buf) + if err != nil { + return err + } + + result[id] = output + } + + *typ = result + + return nil + } + return tlv.NewTypeForEncodingErr(val, "map[asset.ID]SendOutput") +} diff --git a/proof/records.go b/proof/records.go index ee8302cca..745cf6638 100644 --- a/proof/records.go +++ b/proof/records.go @@ -2,6 +2,7 @@ package proof import ( "bytes" + "crypto/sha256" "net/url" "github.com/btcsuite/btcd/btcec/v2" @@ -54,6 +55,29 @@ const ( MetaRevealUniverseCommitments tlv.Type = 7 MetaRevealCanonicalUniversesType tlv.Type = 9 MetaRevealDelegationKeyType tlv.Type = 11 + + // SendFragmentVersionType is the TLV type of the send fragment version. + SendFragmentVersionType tlv.Type = 0 + + // SendFragmentBlockHeaderType is the TLV type of the send fragment's + // block header. + SendFragmentBlockHeaderType tlv.Type = 2 + + // SendFragmentBlockHeightType is the TLV type of the send fragment's + // block height. + SendFragmentBlockHeightType tlv.Type = 4 + + // SendFragmentOutPointType is the TLV type of the send fragment's + // outpoint. + SendFragmentOutPointType tlv.Type = 6 + + // SendFragmentOutputsType is the TLV type of the send fragment's + // outputs. + SendFragmentOutputsType tlv.Type = 8 + + // SendFragmentTaprootAssetRootType is the TLV type of the send + // fragment's Taproot Asset root. This is used to + SendFragmentTaprootAssetRootType tlv.Type = 10 ) // KnownProofTypes is a set of all known proof TLV types. This set is asserted @@ -97,6 +121,15 @@ var KnownMetaRevealTypes = fn.NewSet( MetaRevealDelegationKeyType, ) +// KnownSendFragmentTypes is a set of all known send fragment TLV types. +// This set is asserted to be complete by a check in the BIP test vector unit +// tests. +var KnownSendFragmentTypes = fn.NewSet( + SendFragmentVersionType, SendFragmentBlockHeaderType, + SendFragmentBlockHeightType, SendFragmentOutPointType, + SendFragmentOutputsType, SendFragmentTaprootAssetRootType, +) + func VersionRecord(version *TransitionVersion) tlv.Record { return tlv.MakeStaticRecord( VersionType, version, 4, VersionEncoder, VersionDecoder, @@ -514,3 +547,53 @@ func AltLeavesRecord(leaves *[]asset.AltLeaf[asset.Asset]) tlv.Record { asset.AltLeavesDecoder, ) } + +func FragmentVersionRecord(version *SendFragmentVersion) tlv.Record { + return tlv.MakeStaticRecord( + SendFragmentVersionType, version, 1, FragmentVersionEncoder, + FragmentVersionDecoder, + ) +} + +func FragmentBlockHeaderRecord(header *wire.BlockHeader) tlv.Record { + return tlv.MakeStaticRecord( + SendFragmentBlockHeaderType, header, wire.MaxBlockHeaderPayload, + BlockHeaderEncoder, BlockHeaderDecoder, + ) +} + +func FragmentBlockHeightRecord(height *uint32) tlv.Record { + return tlv.MakeStaticRecord( + SendFragmentBlockHeightType, height, 4, tlv.EUint32, + tlv.DUint32, + ) +} + +func FragmentOutPointRecord(prevOut *wire.OutPoint) tlv.Record { + return tlv.MakeStaticRecord( + SendFragmentOutPointType, prevOut, 32+4, asset.OutPointEncoder, + asset.OutPointDecoder, + ) +} + +func FragmentOutputsRecord(outputs *map[asset.ID]SendOutput) tlv.Record { + sizeFunc := func() uint64 { + var buf bytes.Buffer + err := SendOutputsEncoder(&buf, outputs, &[8]byte{}) + if err != nil { + panic(err) + } + return uint64(len(buf.Bytes())) + } + return tlv.MakeDynamicRecord( + SendFragmentOutputsType, outputs, sizeFunc, SendOutputsEncoder, + SendOutputsDecoder, + ) +} + +func FragmentTaprootAssetRootRecord(root *[sha256.Size]byte) tlv.Record { + return tlv.MakeStaticRecord( + SendFragmentTaprootAssetRootType, root, sha256.Size, + tlv.EBytes32, tlv.DBytes32, + ) +} diff --git a/proof/send.go b/proof/send.go new file mode 100644 index 000000000..9efb880ac --- /dev/null +++ b/proof/send.go @@ -0,0 +1,218 @@ +package proof + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io" + "net/url" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightningnetwork/lnd/tlv" +) + +const ( + // MaxSendFragmentOutputs is the maximum number of outputs that can be + // included in a single send fragment. This is to limit the size of the + // encrypted message that is sent to the auth mailbox server. That means + // a single send to an address can only use at most pieces from 256 + // different asset tranches, which should not really ever be a limiting + // factor in practice. + MaxSendFragmentOutputs = 256 +) + +// SendFragmentVersion is the version of the send fragment. +type SendFragmentVersion uint8 + +const ( + // SendFragmentVersionUnknown is the version of the send fragment that + // indicates an unknown version. This is used to signal that the + // fragment version is not recognized. + SendFragmentVersionUnknown SendFragmentVersion = 0 + + // SendFragmentV0 is the first version of the send fragment. + SendFragmentV0 SendFragmentVersion = 1 + + // LatestVersion is the latest version of the send fragment. + LatestVersion = SendFragmentV0 +) + +// SendOutput is a single asset UTXO or leaf that is being sent to the receiver +// of a V2 TAP address send. It contains the asset version, amount, derivation +// method, and the script key that can be used to spend the output. +type SendOutput struct { + // AssetVersion is the version of the asset that is being sent. + AssetVersion asset.Version + + // Amount is the amount of this asset output. + Amount uint64 + + // DerivationMethod is the method used to derive the script key for this + // output. + DerivationMethod asset.ScriptKeyDerivationMethod + + // ScriptKey is the serialized script key that can be used to spend the + // output. The script key is derived from the recipient's internal key + // specified in the TAP address, and the asset ID of the output (using + // the derivation method specified in the above field). + ScriptKey asset.SerializedKey +} + +// SendFragment is the message that needs to be sent from the sender to the +// receiver of a V2 TAP address send. It contains all the information required +// to reconstruct the information required to fetch proofs from the universe, +// and to materialize the asset outputs on the receiver's side. We assume that +// the receiver has access to the TAP address that was used to send the assets. +type SendFragment struct { + // Version is the version of the send fragment. + Version SendFragmentVersion + + // BlockHeader is the block header of the block that contains the + // transaction. This is useful to fetch the full block to extract the + // transaction on a node that doesn't have the transaction index + // enabled. + BlockHeader wire.BlockHeader + + // BlockHeight is the height of the block that contains the transaction. + BlockHeight uint32 + + // OutPoint is the outpoint of the transaction that contains the asset + // outputs that are being sent. + OutPoint wire.OutPoint + + // Outputs is a map of asset IDs to the outputs that are being sent. + Outputs map[asset.ID]SendOutput + + // TaprootAssetRoot is the root of the Taproot Asset commitment tree. + TaprootAssetRoot [sha256.Size]byte + + // UnknownOddTypes is a map of unknown odd types that were encountered + // during decoding. This map is used to preserve unknown types that we + // don't know of yet, so we can still encode them back when serializing. + // This enables forward compatibility with future versions of the + // protocol as it allows new odd (optional) types to be added without + // breaking old clients that don't yet fully understand them. + UnknownOddTypes tlv.TypeMap +} + +// Validate ensures that the send fragment is formally valid. +func (f *SendFragment) Validate() error { + // Ensure the version is known. + if f.Version <= SendFragmentVersionUnknown || + f.Version > LatestVersion { + + return fmt.Errorf("unknown send fragment version: %d", + f.Version) + } + + // Ensure the outputs are not empty and do not exceed the maximum + // allowed number of outputs. + if len(f.Outputs) == 0 || len(f.Outputs) > MaxSendFragmentOutputs { + return fmt.Errorf("invalid number of outputs: %d, must be "+ + "between 1 and %d", len(f.Outputs), + MaxSendFragmentOutputs) + } + + return nil +} + +// EncodeRecords returns the encoding records for the SendFragment. +func (f *SendFragment) EncodeRecords() []tlv.Record { + records := []tlv.Record{ + FragmentVersionRecord(&f.Version), + FragmentBlockHeaderRecord(&f.BlockHeader), + FragmentBlockHeightRecord(&f.BlockHeight), + FragmentOutPointRecord(&f.OutPoint), + FragmentOutputsRecord(&f.Outputs), + FragmentTaprootAssetRootRecord(&f.TaprootAssetRoot), + } + + // Add any unknown odd types that were encountered during decoding. + return asset.CombineRecords(records, f.UnknownOddTypes) +} + +// DecodeRecords returns the decoding records for the SendFragment. +func (f *SendFragment) DecodeRecords() []tlv.Record { + return []tlv.Record{ + FragmentVersionRecord(&f.Version), + FragmentBlockHeaderRecord(&f.BlockHeader), + FragmentBlockHeightRecord(&f.BlockHeight), + FragmentOutPointRecord(&f.OutPoint), + FragmentOutputsRecord(&f.Outputs), + FragmentTaprootAssetRootRecord(&f.TaprootAssetRoot), + } +} + +// Encode attempts to encode the SendFragment into the passed io.Writer. +func (f *SendFragment) Encode(w io.Writer) error { + stream, err := tlv.NewStream(f.EncodeRecords()...) + if err != nil { + return err + } + return stream.Encode(w) +} + +// Decode attempts to decode the SendFragment from the passed io.Reader. +func (f *SendFragment) Decode(r io.Reader) error { + stream, err := tlv.NewStream(f.DecodeRecords()...) + if err != nil { + return err + } + + unknownOddTypes, err := asset.TlvStrictDecodeP2P( + stream, r, KnownSendFragmentTypes, + ) + if err != nil { + return err + } + + f.UnknownOddTypes = unknownOddTypes + + return nil +} + +// DecodeSendFragment decodes a serialized send fragment from the given blob of +// bytes. +func DecodeSendFragment(blob []byte) (*SendFragment, error) { + fragment := &SendFragment{ + Outputs: make(map[asset.ID]SendOutput), + } + + if err := fragment.Decode(bytes.NewReader(blob)); err != nil { + return nil, fmt.Errorf("unable to decode send fragment: %w", + err) + } + + return fragment, nil +} + +// SendManifest holds the shipping instruction that contains all the information +// required to send a fragment to the receiver of a V2 TAP address send. The +// manifest itself isn't encoded, only the actual fragment is serialized and +// encrypted and sent to the auth mailbox server as a message. The manifest +// contains all the meta information required to send the encrypted fragment to +// the auth mailbox server, including the TX proof to show we own the output and +// have committed it to the chain. +type SendManifest struct { + // TxProof is the proof of the transaction that contains the asset + // outputs that are being sent. This is used as proof-of work to show + // to the auth mailbox server. + TxProof TxProof + + // Receiver is the receiver's public key of the asset outputs, used + // to decrypt the send fragment. This is the internal key of the address + // that was used to send the assets. + Receiver btcec.PublicKey + + // CourierURL is the URL of the auth mailbox server that will be used to + // send the fragment to the receiver. + CourierURL url.URL + + // Fragment is the send fragment that contains all the information the + // receiver needs to reconstruct the asset outputs and fetch proofs from + // the universe. The fragment will be encoded and encrypted and uploaded + // as a message to the auth mailbox server. + Fragment SendFragment +} diff --git a/proof/send_test.go b/proof/send_test.go new file mode 100644 index 000000000..0eb0ebd0a --- /dev/null +++ b/proof/send_test.go @@ -0,0 +1,91 @@ +package proof + +import ( + "bytes" + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestSendFragmentEncodeDecode tests the encoding and decoding of SendFragment +// structs. +func TestSendFragmentEncodeDecode(t *testing.T) { + output1 := SendOutput{ + AssetVersion: asset.Version(1), + Amount: 100, + DerivationMethod: asset.ScriptKeyDerivationUniquePedersen, + ScriptKey: asset.SerializedKey{0x04}, + } + output2 := SendOutput{ + AssetVersion: asset.Version(2), + Amount: 200, + DerivationMethod: asset.ScriptKeyDerivationUniquePedersen, + ScriptKey: asset.SerializedKey{0x05, 0x06}, + } + + tests := []struct { + name string + fragment SendFragment + }{ + { + name: "basic fragment", + fragment: SendFragment{ + Version: SendFragmentV0, + BlockHeader: wire.BlockHeader{ + Version: 1, + PrevBlock: [32]byte{0x01}, + MerkleRoot: [32]byte{0x02}, + Timestamp: time.Unix(1234567890, 0), + Bits: 0x1d00ffff, + Nonce: 0, + }, + BlockHeight: 1234, + OutPoint: wire.OutPoint{ + Hash: [32]byte{0x03}, + Index: 1, + }, + Outputs: map[asset.ID]SendOutput{ + {0x01}: output1, + {0x02}: output2, + }, + TaprootAssetRoot: [32]byte{ + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, + }, + UnknownOddTypes: tlv.TypeMap{ + 0x1001: []byte{0x05, 0x06}, + }, + }, + }, + { + name: "empty fragment", + fragment: SendFragment{ + Version: SendFragmentV0, + BlockHeader: wire.BlockHeader{ + Timestamp: time.Unix(1234567890, 0), + }, + Outputs: map[asset.ID]SendOutput{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Encode the fragment. + var buf bytes.Buffer + err := tt.fragment.Encode(&buf) + require.NoError(t, err) + + // Decode the fragment. + var decodedFragment SendFragment + err = decodedFragment.Decode(&buf) + require.NoError(t, err) + + // Verify the decoded fragment matches the original. + require.Equal(t, tt.fragment, decodedFragment) + }) + } +}