// BUGS: // - MinimumRecentTransactions is ignored. // - Wrong error codes when a block height or hash is not recognized func (s *walletServer) GetTransactions(ctx context.Context, req *pb.GetTransactionsRequest) ( resp *pb.GetTransactionsResponse, err error) { var startBlock, endBlock *wallet.BlockIdentifier if req.StartingBlockHash != nil && req.StartingBlockHeight != 0 { return nil, errors.New( "starting block hash and height may not be specified simultaneously") } else if req.StartingBlockHash != nil { startBlockHash, err := chainhash.NewHash(req.StartingBlockHash) if err != nil { return nil, grpc.Errorf(codes.InvalidArgument, "%s", err.Error()) } startBlock = wallet.NewBlockIdentifierFromHash(startBlockHash) } else if req.StartingBlockHeight != 0 { startBlock = wallet.NewBlockIdentifierFromHeight(req.StartingBlockHeight) } if req.EndingBlockHash != nil && req.EndingBlockHeight != 0 { return nil, grpc.Errorf(codes.InvalidArgument, "ending block hash and height may not be specified simultaneously") } else if req.EndingBlockHash != nil { endBlockHash, err := chainhash.NewHash(req.EndingBlockHash) if err != nil { return nil, grpc.Errorf(codes.InvalidArgument, "%s", err.Error()) } endBlock = wallet.NewBlockIdentifierFromHash(endBlockHash) } else if req.EndingBlockHeight != 0 { endBlock = wallet.NewBlockIdentifierFromHeight(req.EndingBlockHeight) } var minRecentTxs int if req.MinimumRecentTransactions != 0 { if endBlock != nil { return nil, grpc.Errorf(codes.InvalidArgument, "ending block and minimum number of recent transactions "+ "may not be specified simultaneously") } minRecentTxs = int(req.MinimumRecentTransactions) if minRecentTxs < 0 { return nil, grpc.Errorf(codes.InvalidArgument, "minimum number of recent transactions may not be negative") } } _ = minRecentTxs gtr, err := s.wallet.GetTransactions(startBlock, endBlock, ctx.Done()) if err != nil { return nil, translateError(err) } return marshalGetTransactionsResult(gtr) }
// deserializeTicketHashes deserializes a list of ticket hashes. Empty but // non-nil slices are deserialized empty. func deserializeTicketHashes(b []byte) (TicketHashes, error) { if b != nil && len(b) == 0 { return make(TicketHashes, 0), nil } if len(b) < chainhash.HashSize { return nil, ticketDBError(ErrTicketHashesShortRead, "short read when "+ "deserializing ticket hashes") } if len(b)%chainhash.HashSize != 0 { return nil, ticketDBError(ErrTicketHashesCorrupt, "corrupt data found "+ "when deserializing ticket hashes") } entries := len(b) / chainhash.HashSize ths := make(TicketHashes, entries) offset := 0 for i := 0; i < entries; i++ { hash, err := chainhash.NewHash( b[offset : offset+chainhash.HashSize]) if err != nil { return nil, ticketDBError(ErrUndoDataCorrupt, "corrupt hash found "+ "when deserializing block undo data") } offset += chainhash.HashSize ths[i] = *hash } return ths, nil }
// loadManager returns a new stake manager that results from loading it from // the passed opened database. The public passphrase is required to decrypt the // public keys. func (s *StakeStore) loadOwnedSStxs(ns walletdb.ReadBucket) error { // Regenerate the list of tickets. // Perform all database lookups in a read-only view. ticketList := make(map[chainhash.Hash]struct{}) // Open the sstx records database. bucket := ns.NestedReadBucket(sstxRecordsBucketName) // Store each key sequentially. err := bucket.ForEach(func(k []byte, v []byte) error { var errNewHash error var hash *chainhash.Hash hash, errNewHash = chainhash.NewHash(k) if errNewHash != nil { return errNewHash } ticketList[*hash] = struct{}{} return nil }) if err != nil { return err } s.ownedSStxs = ticketList return nil }
// DbLoadAllTickets loads all the live tickets from the database into a treap. func DbLoadAllTickets(dbTx database.Tx, ticketBucket []byte) (*tickettreap.Immutable, error) { meta := dbTx.Metadata() bucket := meta.Bucket(ticketBucket) treap := tickettreap.NewImmutable() err := bucket.ForEach(func(k []byte, v []byte) error { if len(v) < 5 { return ticketDBError(ErrLoadAllTickets, fmt.Sprintf("short "+ "read for ticket key %x when loading tickets", k)) } h, err := chainhash.NewHash(k) if err != nil { return err } treapKey := tickettreap.Key(*h) missed, revoked, spent, expired := undoBitFlagsFromByte(v[4]) treapValue := &tickettreap.Value{ Height: dbnamespace.ByteOrder.Uint32(v[0:4]), Missed: missed, Revoked: revoked, Spent: spent, Expired: expired, } treap = treap.Put(treapKey, treapValue) return nil }) if err != nil { return nil, ticketDBError(ErrLoadAllTickets, fmt.Sprintf("failed to "+ "load all tickets for the bucket %s", string(ticketBucket))) } return treap, nil }
// deserializeUserInvalTickets deserializes the passed serialized pool // users invalid tickets information. func deserializeUserInvalTickets(serializedTickets []byte) ([]*chainhash.Hash, error) { // Cursory check to make sure that the number of records // makes sense. if len(serializedTickets)%chainhash.HashSize != 0 { err := io.ErrUnexpectedEOF return nil, err } numRecords := len(serializedTickets) / chainhash.HashSize records := make([]*chainhash.Hash, numRecords) // Loop through all the ssgen records, deserialize them, and // store them. for i := 0; i < numRecords; i++ { start := i * chainhash.HashSize end := (i + 1) * chainhash.HashSize h, err := chainhash.NewHash(serializedTickets[start:end]) if err != nil { str := "problem deserializing stake pool invalid user tickets" return nil, stakeStoreError(ErrDatabase, str, err) } records[i] = h } return records, nil }
// SSGenBlockVotedOn takes an SSGen tx and returns the block voted on in the // first OP_RETURN by hash and height. // // This function is only safe to be called on a transaction that // has passed IsSSGen. func SSGenBlockVotedOn(tx *wire.MsgTx) (chainhash.Hash, uint32, error) { // Get the block header hash. blockSha, err := chainhash.NewHash(tx.TxOut[0].PkScript[2:34]) if err != nil { return chainhash.Hash{}, 0, err } // Get the block height. height := binary.LittleEndian.Uint32(tx.TxOut[0].PkScript[34:38]) return *blockSha, height, nil }
func NewCoin(index int64, value dcrutil.Amount, numConfs int64) coinset.Coin { h := fastsha256.New() h.Write([]byte(fmt.Sprintf("%d", index))) hash, _ := chainhash.NewHash(h.Sum(nil)) c := &TestCoin{ TxHash: hash, TxIndex: 0, TxValue: value, TxNumConfs: numConfs, } return coinset.Coin(c) }
/* convert target come from getwork to big integer "ffffffffffffffffffffffffffffffffffffffffffffffffffffff3f00000000" */ func TargetStrToDiff(targetHex string) *big.Int { hashbytes, err := hex.DecodeString(targetHex) if err != nil { return big.NewInt(0) } hash, err := chainhash.NewHash(hashbytes) if err != nil { return big.NewInt(0) } targetdiff := blockchain.ShaHashToBig(hash) if targetdiff.Cmp(big.NewInt(0)) != 0 { targetdiff.Div(PowLimit, targetdiff) } return targetdiff }
// deserializeBlockUndoData deserializes a list of UndoTicketData for an entire // block. Empty but non-nil slices are deserialized empty. func deserializeBlockUndoData(b []byte) ([]UndoTicketData, error) { if b != nil && len(b) == 0 { return make([]UndoTicketData, 0), nil } if len(b) < undoTicketDataSize { return nil, ticketDBError(ErrUndoDataShortRead, "short read when "+ "deserializing block undo data") } if len(b)%undoTicketDataSize != 0 { return nil, ticketDBError(ErrUndoDataCorrupt, "corrupt data found "+ "when deserializing block undo data") } entries := len(b) / undoTicketDataSize utds := make([]UndoTicketData, entries) offset := 0 for i := 0; i < entries; i++ { hash, err := chainhash.NewHash( b[offset : offset+chainhash.HashSize]) if err != nil { return nil, ticketDBError(ErrUndoDataCorrupt, "corrupt hash found "+ "when deserializing block undo data") } offset += chainhash.HashSize height := dbnamespace.ByteOrder.Uint32(b[offset : offset+4]) offset += 4 missed, revoked, spent, expired := undoBitFlagsFromByte(b[offset]) offset++ utds[i] = UndoTicketData{ TicketHash: *hash, TicketHeight: height, Missed: missed, Revoked: revoked, Spent: spent, Expired: expired, } } return utds, nil }
// dumpSSRtxTickets fetches the entire list of tickets spent as revocations // byt this wallet. func (s *StakeStore) dumpSSRtxTickets(ns walletdb.ReadBucket) ([]chainhash.Hash, error) { var ticketList []chainhash.Hash // Open the revocation records database. bucket := ns.NestedReadBucket(ssrtxRecordsBucketName) // Store each hash sequentially. err := bucket.ForEach(func(k []byte, v []byte) error { ticket, errDeser := chainhash.NewHash(k) if errDeser != nil { return errDeser } ticketList = append(ticketList, *ticket) return nil }) return ticketList, err }
// BenchmarkMruInventoryList performs basic benchmarks on the most recently // used inventory handling. func BenchmarkMruInventoryList(b *testing.B) { // Create a bunch of fake inventory vectors to use in benchmarking // the mru inventory code. b.StopTimer() numInvVects := 100000 invVects := make([]*wire.InvVect, 0, numInvVects) for i := 0; i < numInvVects; i++ { hashBytes := make([]byte, chainhash.HashSize) rand.Read(hashBytes) hash, _ := chainhash.NewHash(hashBytes) iv := wire.NewInvVect(wire.InvTypeBlock, hash) invVects = append(invVects, iv) } b.StartTimer() // Benchmark the add plus evicition code. limit := 20000 mruInvMap := NewMruInventoryMap(uint(limit)) for i := 0; i < b.N; i++ { mruInvMap.Add(invVects[i%numInvVects]) } }
// NewHash256PRNG creates a pointer to a newly created hash256PRNG. func NewHash256PRNG(seed []byte) *Hash256PRNG { // idx and lastHash are automatically initialized // as 0. We initialize the seed by appending a constant // to it and hashing to give 32 bytes. This ensures // that regardless of the input, the PRNG is always // doing a short number of rounds because it only // has to hash < 64 byte messages. The constant is // derived from the hexadecimal representation of // pi. cst := []byte{0x24, 0x3F, 0x6A, 0x88, 0x85, 0xA3, 0x08, 0xD3} hp := new(Hash256PRNG) hp.seed = chainhash.HashFuncB(append(seed, cst...)) initLH, err := chainhash.NewHash(hp.seed) if err != nil { return nil } hp.seedState = *initLH hp.lastHash = *initLH hp.idx = 0 return hp }
func TestBlockHeaderHashing(t *testing.T) { dummyHeader := "0000000049e0b48ade043f729d60095ed92642d96096fe6aba42f2eda" + "632d461591a152267dc840ff27602ce1968a81eb30a43423517207617a0150b56c4f72" + "b803e497f00000000000000000000000000000000000000000000000000000000000000" + "00010000000000000000000000b7000000ffff7f20204e0000000000005800000060010" + "0008b990956000000000000000000000000000000000000000000000000000000000000" + "0000000000000000ABCD" // This hash has reversed endianness compared to what chainhash spits out. hashStr := "0d40d58703482d81d711be0ffc1b313788d3c3937e1617e4876661d33a8c4c41" hashB, _ := hex.DecodeString(hashStr) hash, _ := chainhash.NewHash(hashB) vecH, _ := hex.DecodeString(dummyHeader) r := bytes.NewReader(vecH) var bh wire.BlockHeader bh.Deserialize(r) hash2 := bh.BlockSha() if !hash2.IsEqual(hash) { t.Errorf("wrong block sha returned (want %v, got %v)", hash, hash2) } }
// TicketDbThumbprint takes all the tickets in the respective ticket db, // sorts them, hashes their contents into a list, and then hashes that list. // The resultant hash is the thumbprint of the ticket database, and should // be the same across all clients that are synced to the same block. Returns // an array of hashes len 3, containing (1) live tickets (2) spent tickets // and (3) missed tickets. // Do NOT use on mainnet or in production. For debug use only! Make sure // the blockchain is frozen when you call this function. func TicketDbThumbprint(tmdb *stake.TicketDB, chainParams *chaincfg.Params) ([]*chainhash.Hash, error) { // Container for the three master hashes to go into. dbThumbprints := make([]*chainhash.Hash, 3, 3) // (1) Live tickets. allLiveTickets := stake.NewTicketDataSliceEmpty() for i := 0; i < stake.BucketsSize; i++ { bucketTickets, err := tmdb.DumpLiveTickets(uint8(i)) if err != nil { return nil, err } for _, td := range bucketTickets { allLiveTickets = append(allLiveTickets, td) } } // Sort by the number data hash, since we already have this implemented // and it's also unique. sort.Sort(allLiveTickets) // Create a buffer, dump all the data into it, and hash. var buf bytes.Buffer for _, td := range allLiveTickets { writeTicketDataToBuf(&buf, td) } liveHash := chainhash.HashFunc(buf.Bytes()) liveThumbprint, err := chainhash.NewHash(liveHash[:]) if err != nil { return nil, err } dbThumbprints[0] = liveThumbprint // (2) Spent tickets. height := tmdb.GetTopBlock() allSpentTickets := stake.NewTicketDataSliceEmpty() for i := int64(chainParams.StakeEnabledHeight); i <= height; i++ { bucketTickets, err := tmdb.DumpSpentTickets(i) if err != nil { return nil, err } for _, td := range bucketTickets { allSpentTickets = append(allSpentTickets, td) } } sort.Sort(allSpentTickets) buf.Reset() // Flush buffer for _, td := range allSpentTickets { writeTicketDataToBuf(&buf, td) } spentHash := chainhash.HashFunc(buf.Bytes()) spentThumbprint, err := chainhash.NewHash(spentHash[:]) if err != nil { return nil, err } dbThumbprints[1] = spentThumbprint // (3) Missed tickets. allMissedTickets := stake.NewTicketDataSliceEmpty() missedTickets, err := tmdb.DumpMissedTickets() if err != nil { return nil, err } for _, td := range missedTickets { allMissedTickets = append(allMissedTickets, td) } sort.Sort(allMissedTickets) buf.Reset() // Flush buffer missedHash := chainhash.HashFunc(buf.Bytes()) missedThumbprint, err := chainhash.NewHash(missedHash[:]) if err != nil { return nil, err } dbThumbprints[2] = missedThumbprint return dbThumbprints, nil }
// BUGS: // - MinimumRecentTransactions is ignored. // - Wrong error codes when a block height or hash is not recognized func (s *walletServer) GetTransactions(req *pb.GetTransactionsRequest, server pb.WalletService_GetTransactionsServer) error { var startBlock, endBlock *wallet.BlockIdentifier if req.StartingBlockHash != nil && req.StartingBlockHeight != 0 { return grpc.Errorf(codes.InvalidArgument, "starting block hash and height may not be specified simultaneously") } else if req.StartingBlockHash != nil { startBlockHash, err := chainhash.NewHash(req.StartingBlockHash) if err != nil { return grpc.Errorf(codes.InvalidArgument, "%s", err.Error()) } startBlock = wallet.NewBlockIdentifierFromHash(startBlockHash) } else if req.StartingBlockHeight != 0 { startBlock = wallet.NewBlockIdentifierFromHeight(req.StartingBlockHeight) } if req.EndingBlockHash != nil && req.EndingBlockHeight != 0 { return grpc.Errorf(codes.InvalidArgument, "ending block hash and height may not be specified simultaneously") } else if req.EndingBlockHash != nil { endBlockHash, err := chainhash.NewHash(req.EndingBlockHash) if err != nil { return grpc.Errorf(codes.InvalidArgument, "%s", err.Error()) } endBlock = wallet.NewBlockIdentifierFromHash(endBlockHash) } else if req.EndingBlockHeight != 0 { endBlock = wallet.NewBlockIdentifierFromHeight(req.EndingBlockHeight) } var minRecentTxs int if req.MinimumRecentTransactions != 0 { if endBlock != nil { return grpc.Errorf(codes.InvalidArgument, "ending block and minimum number of recent transactions "+ "may not be specified simultaneously") } minRecentTxs = int(req.MinimumRecentTransactions) if minRecentTxs < 0 { return grpc.Errorf(codes.InvalidArgument, "minimum number of recent transactions may not be negative") } } _ = minRecentTxs gtr, err := s.wallet.GetTransactions(startBlock, endBlock, server.Context().Done()) if err != nil { return translateError(err) } for i := range gtr.MinedTransactions { resp := &pb.GetTransactionsResponse{ MinedTransactions: marshalBlock(>r.MinedTransactions[i]), } err = server.Send(resp) if err != nil { return err } } if len(gtr.UnminedTransactions) > 0 { resp := &pb.GetTransactionsResponse{ UnminedTransactions: marshalTransactionDetails(gtr.UnminedTransactions), } err = server.Send(resp) if err != nil { return err } } return nil }
// TestMerkleBlock tests the MsgMerkleBlock API. func TestMerkleBlock(t *testing.T) { pver := ProtocolVersion // Test block header. bh := NewBlockHeader( int32(pver), &testBlock.Header.PrevBlock, // PrevHash &testBlock.Header.MerkleRoot, // MerkleRootHash &testBlock.Header.StakeRoot, // StakeRoot uint16(0x0000), // VoteBits [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // FinalState uint16(0x0000), // Voters uint8(0x00), // FreshStake uint8(0x00), // Revocations uint32(0), // Poolsize testBlock.Header.Bits, // Bits int64(0x0000000000000000), // Sbits uint32(1), // Height uint32(0), // Size testBlock.Header.Nonce, // Nonce [32]byte{}, // ExtraData uint32(0x7e1eca57), // StakeVersion ) // Ensure the command is expected value. wantCmd := "merkleblock" msg := NewMsgMerkleBlock(bh) if cmd := msg.Command(); cmd != wantCmd { t.Errorf("NewMsgBlock: wrong command - got %v want %v", cmd, wantCmd) } // Ensure max payload is expected value for latest protocol version. // Num addresses (varInt) + max allowed addresses. wantPayload := uint32(1000000) maxPayload := msg.MaxPayloadLength(pver) if maxPayload != wantPayload { t.Errorf("MaxPayloadLength: wrong max payload length for "+ "protocol version %d - got %v, want %v", pver, maxPayload, wantPayload) } // Load maxTxPerBlock hashes data := make([]byte, 32) for i := 0; i < MaxTxPerTxTree; i++ { rand.Read(data) hash, err := chainhash.NewHash(data) if err != nil { t.Errorf("NewShaHash failed: %v\n", err) return } if err = msg.AddTxHash(hash); err != nil { t.Errorf("AddTxHash failed: %v\n", err) return } if err = msg.AddSTxHash(hash); err != nil { t.Errorf("AddSTxHash failed: %v\n", err) return } } // Add one more Tx to test failure. rand.Read(data) hash, err := chainhash.NewHash(data) if err != nil { t.Errorf("NewShaHash failed: %v\n", err) return } if err = msg.AddTxHash(hash); err == nil { t.Errorf("AddTxHash succeeded when it should have failed") return } // Add one more STx to test failure. rand.Read(data) hash, err = chainhash.NewHash(data) if err != nil { t.Errorf("NewShaHash failed: %v\n", err) return } if err = msg.AddSTxHash(hash); err == nil { t.Errorf("AddTxHash succeeded when it should have failed") return } // Test encode with latest protocol version. var buf bytes.Buffer err = msg.BtcEncode(&buf, pver) if err != nil { t.Errorf("encode of MsgMerkleBlock failed %v err <%v>", msg, err) } // Test decode with latest protocol version. readmsg := MsgMerkleBlock{} err = readmsg.BtcDecode(&buf, pver) if err != nil { t.Errorf("decode of MsgMerkleBlock failed [%v] err <%v>", buf, err) } // Force extra hash to test maxTxPerBlock. msg.Hashes = append(msg.Hashes, hash) err = msg.BtcEncode(&buf, pver) if err == nil { t.Errorf("encode of MsgMerkleBlock succeeded with too many " + "tx hashes when it should have failed") return } // Force too many flag bytes to test maxFlagsPerMerkleBlock. // Reset the number of hashes back to a valid value. msg.Hashes = msg.Hashes[len(msg.Hashes)-1:] msg.Flags = make([]byte, maxFlagsPerMerkleBlock+1) err = msg.BtcEncode(&buf, pver) if err == nil { t.Errorf("encode of MsgMerkleBlock succeeded with too many " + "flag bytes when it should have failed") return } }
// TestShaHash tests the ShaHash API. func TestShaHash(t *testing.T) { // Hash of block 234439. blockHashStr := "14a0810ac680a3eb3f82edc878cea25ec41d6b790744e5daeef" blockHash, err := chainhash.NewHashFromStr(blockHashStr) if err != nil { t.Errorf("NewShaHashFromStr: %v", err) } // Hash of block 234440 as byte slice. buf := []byte{ 0x79, 0xa6, 0x1a, 0xdb, 0xc6, 0xe5, 0xa2, 0xe1, 0x39, 0xd2, 0x71, 0x3a, 0x54, 0x6e, 0xc7, 0xc8, 0x75, 0x63, 0x2e, 0x75, 0xf1, 0xdf, 0x9c, 0x3f, 0xa6, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, } hash, err := chainhash.NewHash(buf) if err != nil { t.Errorf("NewShaHash: unexpected error %v", err) } // Ensure proper size. if len(hash) != chainhash.HashSize { t.Errorf("NewShaHash: hash length mismatch - got: %v, want: %v", len(hash), chainhash.HashSize) } // Ensure contents match. if !bytes.Equal(hash[:], buf) { t.Errorf("NewShaHash: hash contents mismatch - got: %v, want: %v", hash[:], buf) } // Ensure contents of hash of block 234440 don't match 234439. if hash.IsEqual(blockHash) { t.Errorf("IsEqual: hash contents should not match - got: %v, want: %v", hash, blockHash) } // Set hash from byte slice and ensure contents match. err = hash.SetBytes(blockHash.Bytes()) if err != nil { t.Errorf("SetBytes: %v", err) } if !hash.IsEqual(blockHash) { t.Errorf("IsEqual: hash contents mismatch - got: %v, want: %v", hash, blockHash) } // Invalid size for SetBytes. err = hash.SetBytes([]byte{0x00}) if err == nil { t.Errorf("SetBytes: failed to received expected err - got: nil") } // Invalid size for NewShaHash. invalidHash := make([]byte, chainhash.HashSize+1) _, err = chainhash.NewHash(invalidHash) if err == nil { t.Errorf("NewShaHash: failed to received expected err - got: nil") } }
func (s *Store) debugBucketUnspentString(ns walletdb.ReadBucket, inclUnmined bool) (string, error) { var unspent []*unspentDebugData var op wire.OutPoint var block Block err := ns.NestedReadBucket(bucketUnspent).ForEach(func(k, v []byte) error { err := readCanonicalOutPoint(k, &op) if err != nil { return err } existsUnmined := false if existsRawUnminedInput(ns, k) != nil { // Skip including unmined if specified. if !inclUnmined { return nil } existsUnmined = true } err = readUnspentBlock(v, &block) if err != nil { return err } thisUnspentOutput := &unspentDebugData{ op, existsUnmined, block.Hash, block.Height, } unspent = append(unspent, thisUnspentOutput) return nil }) if err != nil { if _, ok := err.(Error); ok { return "", err } str := "failed iterating unspent bucket" return "", storeError(ErrDatabase, str, err) } sort.Sort(ByOutpoint(unspent)) var buffer bytes.Buffer str := fmt.Sprintf("Unspent outputs\n\n") buffer.WriteString(str) // Create a buffer, dump all the data into it, and hash. var thumbprintBuf bytes.Buffer for _, udd := range unspent { str = fmt.Sprintf("Hash: %v, Index: %v, Tree: %v, Unmined: %v, "+ "Block: %v, Block height: %v\n", udd.outPoint.Hash, udd.outPoint.Index, udd.outPoint.Tree, udd.unmined, udd.block, udd.blockHeight) buffer.WriteString(str) writeUnspentDebugDataToBuf(&thumbprintBuf, udd) } unspentHash := chainhash.HashFunc(thumbprintBuf.Bytes()) unspentThumbprint, err := chainhash.NewHash(unspentHash[:]) if err != nil { return "", err } str = fmt.Sprintf("\nUnspent outputs thumbprint: %v", unspentThumbprint) buffer.WriteString(str) return buffer.String(), nil }