diff --git a/cmd/tapcli/universe.go b/cmd/tapcli/universe.go index 3a5349b94..6cc52bfcb 100644 --- a/cmd/tapcli/universe.go +++ b/cmd/tapcli/universe.go @@ -3,12 +3,8 @@ package main import ( "bytes" "encoding/hex" - "errors" "fmt" - "strconv" - "strings" - "github.com/btcsuite/btcd/chaincfg/chainhash" tap "github.com/lightninglabs/taproot-assets" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/taprpc/universerpc" @@ -280,41 +276,18 @@ var universeProofQueryCommand = cli.Command{ Action: universeProofQuery, } -func parseUniOutpoint(ctx *cli.Context) (*universerpc.Outpoint, error) { - // Parse a bitcoin outpoint in the form txid:index into a - // wire.OutPoint struct. - parts := strings.Split(ctx.String(outpointName), ":") - if len(parts) != 2 { - return nil, errors.New("outpoint should be of " + - "the form txid:index") - } - txidStr := parts[0] - if hex.DecodedLen(len(txidStr)) != chainhash.HashSize { - return nil, fmt.Errorf("invalid hex-encoded "+ - "txid %v", txidStr) - } - - outputIndex, err := strconv.ParseInt(parts[1], 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid output "+ - "index: %v", err) - } - - return &universerpc.Outpoint{ - HashStr: txidStr, - Index: int32(outputIndex), - }, nil -} - func parseAssetKey(ctx *cli.Context) (*universerpc.AssetKey, error) { - outpoint, err := parseUniOutpoint(ctx) + outpoint, err := tap.UnmarshalOutpoint(ctx.String(outpointName)) if err != nil { return nil, err } return &universerpc.AssetKey{ Outpoint: &universerpc.AssetKey_Op{ - Op: outpoint, + Op: &unirpc.Outpoint{ + HashStr: outpoint.Hash.String(), + Index: int32(outpoint.Index), + }, }, ScriptKey: &universerpc.AssetKey_ScriptKeyStr{ ScriptKeyStr: ctx.String(scriptKeyName), diff --git a/itest/assertions.go b/itest/assertions.go index 72b06c011..9ffdb728c 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -479,6 +479,22 @@ func assertAddr(t *testing.T, expected *taprpc.Asset, actual *taprpc.Addr) { require.NotEqual(t, expected.ScriptKey, actual.ScriptKey) } +// assertEqualAsset asserts that two taprpc.Asset objects are equal, ignoring +// node-specific fields like if script keys are local, if the asset is spent, +// or if the anchor information is populated. +func assertAsset(t *testing.T, expected, actual *taprpc.Asset) { + require.Equal(t, expected.Version, actual.Version) + require.Equal(t, expected.AssetGenesis, actual.AssetGenesis) + require.Equal(t, expected.AssetType, actual.AssetType) + require.Equal(t, expected.Amount, actual.Amount) + require.Equal(t, expected.LockTime, actual.LockTime) + require.Equal(t, expected.RelativeLockTime, actual.RelativeLockTime) + require.Equal(t, expected.ScriptVersion, actual.ScriptVersion) + require.Equal(t, expected.ScriptKey, actual.ScriptKey) + require.Equal(t, expected.AssetGroup, actual.AssetGroup) + require.Equal(t, expected.PrevWitnesses, actual.PrevWitnesses) +} + // assertBalanceByID asserts that the balance of a single asset, // specified by ID, on the given daemon is correct. func assertBalanceByID(t *testing.T, tapd *tapdHarness, id []byte, @@ -778,15 +794,15 @@ func assertUniverseStats(t *testing.T, node *tapdHarness, } if numProofs != int(uniStats.NumTotalProofs) { - return fmt.Errorf("expected %v, got %v", + return fmt.Errorf("expected %v proofs, got %v", numProofs, uniStats.NumTotalProofs) } if numSyncs != int(uniStats.NumTotalSyncs) { - return fmt.Errorf("expected %v, got %v", + return fmt.Errorf("expected %v syncs, got %v", numSyncs, uniStats.NumTotalSyncs) } if numAssets != int(uniStats.NumTotalAssets) { - return fmt.Errorf("expected %v, got %v", + return fmt.Errorf("expected %v assets, got %v", numAssets, uniStats.NumTotalAssets) } diff --git a/itest/universe_test.go b/itest/universe_test.go index 93a975623..e349cab52 100644 --- a/itest/universe_test.go +++ b/itest/universe_test.go @@ -7,7 +7,9 @@ import ( "fmt" "io" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" + tap "github.com/lightninglabs/taproot-assets" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/taprpc" unirpc "github.com/lightninglabs/taproot-assets/taprpc/universerpc" @@ -122,6 +124,44 @@ func testUniverseSync(t *harnessTest) { ) assertUniverseKeysEqual(t.t, uniIDs, t.tapd, bob) assertUniverseLeavesEqual(t.t, uniIDs, t.tapd, bob) + + // We should also be able to fetch an asset from Bob's Universe, and + // query for that asset with the compressed script key. + firstAssetID := rpcSimpleAssets[0].AssetGenesis.AssetId + firstScriptKey := hex.EncodeToString(rpcSimpleAssets[0].ScriptKey) + firstOutpoint, err := tap.UnmarshalOutpoint( + rpcSimpleAssets[0].ChainAnchor.AnchorOutpoint, + ) + require.NoError(t.t, err) + require.Len(t.t, firstScriptKey, btcec.PubKeyBytesLenCompressed*2) + + firstAssetProofQuery := unirpc.UniverseKey{ + Id: &unirpc.ID{ + Id: &unirpc.ID_AssetId{ + AssetId: firstAssetID, + }, + }, + LeafKey: &unirpc.AssetKey{ + Outpoint: &unirpc.AssetKey_Op{ + Op: &unirpc.Outpoint{ + HashStr: firstOutpoint.Hash.String(), + Index: int32(firstOutpoint.Index), + }, + }, + ScriptKey: &unirpc.AssetKey_ScriptKeyStr{ + ScriptKeyStr: firstScriptKey, + }, + }, + } + + // The asset fetched from the universe should match the asset minted + // on the main node, ignoring the zero prev witness from minting. + firstAssetUniProof, err := bob.QueryProof(ctxt, &firstAssetProofQuery) + require.NoError(t.t, err) + + firstAssetFromUni := firstAssetUniProof.AssetLeaf.Asset + firstAssetFromUni.PrevWitnesses = nil + assertAsset(t.t, rpcSimpleAssets[0], firstAssetFromUni) } // testUniverseREST tests that we're able to properly query the universe state @@ -232,6 +272,7 @@ func testUniverseFederation(t *harnessTest) { // Now that Bob is active, we'll make a set of assets with the main node. firstAsset := mintAssetsConfirmBatch(t, t.tapd, simpleAssets[:1]) + require.Len(t.t, firstAsset, 1) // We'll now add the main node, as a member of Bob's Universe // federation. We expect that their state is synchronized shortly after diff --git a/rpcserver.go b/rpcserver.go index aa49cb03d..e73583382 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -1097,7 +1097,7 @@ func (r *rpcServer) ExportProof(ctx context.Context, return nil, fmt.Errorf("a valid script key must be specified") } - scriptKey, err := btcec.ParsePubKey(in.ScriptKey) + scriptKey, err := parseUserKey(in.ScriptKey) if err != nil { return nil, fmt.Errorf("invalid script key: %w", err) } @@ -1977,6 +1977,23 @@ func marshalScriptKey(scriptKey asset.ScriptKey) *taprpc.ScriptKey { return rpcScriptKey } +// parseUserKey parses a user-provided script or group key, which can be in +// either the Schnorr or Compressed format. +func parseUserKey(scriptKey []byte) (*btcec.PublicKey, error) { + switch len(scriptKey) { + case schnorr.PubKeyBytesLen: + return schnorr.ParsePubKey(scriptKey) + + // Truncate the key and then parse as a Schnorr key. + case btcec.PubKeyBytesLenCompressed: + return schnorr.ParsePubKey(scriptKey[1:]) + + default: + return nil, fmt.Errorf("unknown script key length: %v", + len(scriptKey)) + } +} + // marshalKeyDescriptor marshals the native key descriptor into the RPC // counterpart. func marshalKeyDescriptor(desc keychain.KeyDescriptor) *taprpc.KeyDescriptor { @@ -2162,7 +2179,7 @@ func unmarshalUniID(rpcID *unirpc.ID) (universe.Identifier, error) { }, nil case rpcID.GetGroupKey() != nil: - groupKey, err := schnorr.ParsePubKey(rpcID.GetGroupKey()) + groupKey, err := parseUserKey(rpcID.GetGroupKey()) if err != nil { return universe.Identifier{}, err } @@ -2179,7 +2196,7 @@ func unmarshalUniID(rpcID *unirpc.ID) (universe.Identifier, error) { // TODO(roasbeef): reuse with above - groupKey, err := schnorr.ParsePubKey(groupKeyBytes) + groupKey, err := parseUserKey(groupKeyBytes) if err != nil { return universe.Identifier{}, err } @@ -2330,6 +2347,34 @@ func (r *rpcServer) AssetLeaves(ctx context.Context, return resp, nil } +// unmarshalOutpoint unmarshals an outpoint from a string received via RPC. +func UnmarshalOutpoint(outpoint string) (*wire.OutPoint, error) { + parts := strings.Split(outpoint, ":") + if len(parts) != 2 { + return nil, errors.New("outpoint should be of form txid:index") + } + + txidStr := parts[0] + if hex.DecodedLen(len(txidStr)) != chainhash.HashSize { + return nil, fmt.Errorf("invalid hex-encoded txid %v", txidStr) + } + + txid, err := chainhash.NewHashFromStr(txidStr) + if err != nil { + return nil, err + } + + outputIndex, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid output index: %v", err) + } + + return &wire.OutPoint{ + Hash: *txid, + Index: uint32(outputIndex), + }, nil +} + // unmarshalLeafKey unmarshals a leaf key from the RPC form. func unmarshalLeafKey(key *unirpc.AssetKey) (universe.BaseKey, error) { var ( @@ -2339,9 +2384,7 @@ func unmarshalLeafKey(key *unirpc.AssetKey) (universe.BaseKey, error) { switch { case key.GetScriptKeyBytes() != nil: - pubKey, err := schnorr.ParsePubKey( - key.GetScriptKeyBytes(), - ) + pubKey, err := parseUserKey(key.GetScriptKeyBytes()) if err != nil { return baseKey, err } @@ -2356,9 +2399,7 @@ func unmarshalLeafKey(key *unirpc.AssetKey) (universe.BaseKey, error) { return baseKey, err } - pubKey, err := schnorr.ParsePubKey( - scriptKeyBytes, - ) + pubKey, err := parseUserKey(scriptKeyBytes) if err != nil { return baseKey, err } @@ -2376,32 +2417,13 @@ func unmarshalLeafKey(key *unirpc.AssetKey) (universe.BaseKey, error) { case key.GetOpStr() != "": // Parse a bitcoin outpoint in the form txid:index into a // wire.OutPoint struct. - parts := strings.Split(key.GetOpStr(), ":") - if len(parts) != 2 { - return baseKey, errors.New("outpoint should be of " + - "the form txid:index") - } - txidStr := parts[0] - if hex.DecodedLen(len(txidStr)) != chainhash.HashSize { - return baseKey, fmt.Errorf("invalid hex-encoded "+ - "txid %v", txidStr) - } - - txid, err := chainhash.NewHashFromStr(txidStr) + outpointStr := key.GetOpStr() + outpoint, err := UnmarshalOutpoint(outpointStr) if err != nil { return baseKey, err } - outputIndex, err := strconv.Atoi(parts[1]) - if err != nil { - return baseKey, fmt.Errorf("invalid output "+ - "index: %v", err) - } - - baseKey.MintingOutpoint = wire.OutPoint{ - Hash: *txid, - Index: uint32(outputIndex), - } + baseKey.MintingOutpoint = *outpoint case key.GetOutpoint() != nil: op := key.GetOp() @@ -2440,22 +2462,26 @@ func (r *rpcServer) marshalIssuanceProof(ctx context.Context, req *unirpc.UniverseKey, proof *universe.IssuanceProof) (*unirpc.AssetProofResponse, error) { - uniRoot, err := marshalUniverseRoot(universe.BaseRoot{ - Node: proof.UniverseRoot, - }) + uniProof, err := marshalUniverseProof(proof.InclusionProof) if err != nil { return nil, err } - uniProof, err := marshalUniverseProof(proof.InclusionProof) + + assetLeaf, err := r.marshalAssetLeaf(ctx, proof.Leaf) if err != nil { return nil, err } - assetLeaf, err := r.marshalAssetLeaf(ctx, proof.Leaf) + uniRoot, err := marshalUniverseRoot(universe.BaseRoot{ + Node: proof.UniverseRoot, + }) if err != nil { return nil, err } + uniRoot.AssetName = assetLeaf.Asset.AssetGenesis.Name + uniRoot.Id = req.Id + return &unirpc.AssetProofResponse{ Req: req, UniverseRoot: uniRoot, @@ -2741,7 +2767,7 @@ func (r *rpcServer) ProveAssetOwnership(ctx context.Context, return nil, fmt.Errorf("a valid script key must be specified") } - scriptKey, err := btcec.ParsePubKey(in.ScriptKey) + scriptKey, err := parseUserKey(in.ScriptKey) if err != nil { return nil, fmt.Errorf("invalid script key: %w", err) } diff --git a/tapdb/universe_federation.go b/tapdb/universe_federation.go index 25da9f6c9..267a27d7f 100644 --- a/tapdb/universe_federation.go +++ b/tapdb/universe_federation.go @@ -3,7 +3,6 @@ package tapdb import ( "context" "errors" - "fmt" "time" "github.com/lightninglabs/taproot-assets/fn" @@ -121,8 +120,7 @@ func (u *UniverseFederationDB) AddServers(ctx context.Context, // Add context to unique constraint errors. var uniqueConstraintErr *ErrSqlUniqueConstraintViolation if errors.As(err, &uniqueConstraintErr) { - return fmt.Errorf("universe name is already added: %w", - err) + return universe.ErrDuplicateUniverse } return err diff --git a/tapdb/universe_federation_test.go b/tapdb/universe_federation_test.go index bfb100362..700978e7e 100644 --- a/tapdb/universe_federation_test.go +++ b/tapdb/universe_federation_test.go @@ -57,7 +57,7 @@ func TestUniverseFederationCRUD(t *testing.T) { // If we try to insert them all again, then we should get an error as // we ensure the host names are unique. err = fedDB.AddServers(ctx, addrs...) - require.ErrorContains(t, err, "universe name is already added") + require.ErrorIs(t, err, universe.ErrDuplicateUniverse) // Next, we should be able to fetch all the active hosts. dbAddrs, err := fedDB.UniverseServers(ctx) diff --git a/universe/auto_syncer.go b/universe/auto_syncer.go index 1ea3a14b8..3ff38a469 100644 --- a/universe/auto_syncer.go +++ b/universe/auto_syncer.go @@ -2,6 +2,7 @@ package universe import ( "context" + "errors" "fmt" "sync" "time" @@ -108,7 +109,10 @@ func (f *FederationEnvoy) Start() error { return NewServerAddrFromStr(a) }) err := f.AddServer(serverAddrs...) - if err != nil { + // On restart, we'll get an error for universe servers already + // inserted in our DB, since we can't store duplicates. + // We can safely ignore that error. + if !errors.Is(err, ErrDuplicateUniverse) { log.Warnf("unable to add universe servers: %v", err) } diff --git a/universe/interface.go b/universe/interface.go index 240a2aced..b4a3aa50d 100644 --- a/universe/interface.go +++ b/universe/interface.go @@ -23,6 +23,10 @@ var ( // ErrNoUniverseServers is returned when no active Universe servers are // found in the DB. ErrNoUniverseServers = fmt.Errorf("no active federation servers") + + // ErrDuplicateUniverse is returned when the Universe server being added + // to the DB already exists. + ErrDuplicateUniverse = fmt.Errorf("universe server already added") ) // Identifier is the identifier for a root/base universe.