Skip to content

Commit ba609e1

Browse files
committed
backend/account/notes: restore transaction notes lost in migration
Transaction notes are stored in json files named using the account identifier. In v4.28.0, account identifiers changed as part of the multi-accounts feature. It used to contain the hash of the signing configurations. As a result, transaction notes set before were not shown anymore, as the wrong file was loaded. This patch restores the legacy transaction notes by loading the legacy transaction note files. In v4.27.0, you could have a unified account and split them into individual accounts (one per signing configuration). Transaction notes were not shared between these accounts even them. In v4.28.0, we removed the split-account setting, so we consider notes from split accounts as well - otherwise they would be lost for good.
1 parent 78f8a60 commit ba609e1

File tree

6 files changed

+278
-27
lines changed

6 files changed

+278
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## [Unreleased]
4+
- Restore lost transaction notes when ugprading to v4.28.0.
45
- Improve error message when EtherScan responds with a rate limit error.
56

67
## 4.28.0 [released 2021-05-27]

backend/accounts/account.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020

2121
"github.com/btcsuite/btcd/wire"
2222
"github.com/btcsuite/btcutil"
23-
"github.com/digitalbitbox/bitbox-wallet-app/backend/accounts/notes"
2423
"github.com/digitalbitbox/bitbox-wallet-app/backend/coins/coin"
2524
"github.com/digitalbitbox/bitbox-wallet-app/backend/signing"
2625
"github.com/digitalbitbox/bitbox-wallet-app/util/observable"
@@ -84,7 +83,7 @@ type Interface interface {
8483
CanVerifyAddresses() (bool, bool, error)
8584
VerifyAddress(addressID string) (bool, error)
8685

87-
Notes() *notes.Notes
86+
TxNote(txID string) string
8887
// ProposeTxnote stores a note. The note is is persisted in the notes database upon calling
8988
// SendTx(). This function must be called before `SendTx()`.
9089
ProposeTxNote(string)

backend/accounts/baseaccount.go

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"encoding/csv"
1919
"fmt"
2020
"io"
21+
"os"
2122
"path"
2223
"sync"
2324
"time"
@@ -65,17 +66,26 @@ type BaseAccount struct {
6566
synced bool
6667
offline error
6768

68-
notes *notes.Notes
69+
// notes handles transaction notes.
70+
//
71+
// It is a slice for migration purposes: from v4.27.0 to v4.28.0, the account identifiers
72+
// changed. The slice contains all possible instances of where notes are stored. The first
73+
// element is the newest, and other elements are notes stored under legacy names. After
74+
// `Initialize()`, this will always have at least one element.
75+
notes []*notes.Notes
6976

7077
proposedTxNote string
7178
proposedTxNoteMu sync.Mutex
79+
80+
log *logrus.Entry
7281
}
7382

7483
// NewBaseAccount creates a new Account instance.
7584
func NewBaseAccount(config *AccountConfig, coin coin.Coin, log *logrus.Entry) *BaseAccount {
7685
account := &BaseAccount{
7786
config: config,
7887
coin: coin,
88+
log: log,
7989
}
8090
account.Synchronizer = synchronizer.NewSynchronizer(
8191
func() { config.OnEvent(EventSyncStarted) },
@@ -131,20 +141,65 @@ func (account *BaseAccount) SetOffline(offline error) {
131141
// Initialize initializes the account. `accountIdentifier` is used as part of the filename of
132142
// account databases.
133143
func (account *BaseAccount) Initialize(accountIdentifier string) error {
134-
notes, err := notes.LoadNotes(path.Join(
144+
txNotes, err := notes.LoadNotes(path.Join(
135145
account.config.NotesFolder,
136146
fmt.Sprintf("%s.json", accountIdentifier),
137147
))
138148
if err != nil {
139149
return err
140150
}
141-
account.notes = notes
142-
return nil
143-
}
151+
account.notes = []*notes.Notes{txNotes}
152+
153+
// Append legacy notes (notes stored in files based on obsolete account identifiers). Account
154+
// identifiers changed from v4.27.0 to v4.28.0.
155+
if len(account.Config().SigningConfigurations) == 0 {
156+
return nil
157+
}
158+
accountNumber, err := account.Config().SigningConfigurations[0].AccountNumber()
159+
if err != nil {
160+
return nil
161+
}
162+
if accountNumber != 0 {
163+
// Up to v4.27.0, we only had one account per coin.
164+
return nil
165+
}
144166

145-
// Notes implements Interface.
146-
func (account *BaseAccount) Notes() *notes.Notes {
147-
return account.notes
167+
legacyConfigurations := signing.ConvertToLegacyConfigurations(account.Config().SigningConfigurations)
168+
var legacyAccountIdentifiers []string
169+
switch account.coin.Code() {
170+
case coin.CodeBTC, coin.CodeTBTC, coin.CodeLTC, coin.CodeTLTC:
171+
legacyAccountIdentifiers = []string{fmt.Sprintf("account-%s-%s", legacyConfigurations.Hash(), account.coin.Code())}
172+
// Also consider split accounts:
173+
for _, cfg := range account.Config().SigningConfigurations {
174+
legacyConfigurations := signing.ConvertToLegacyConfigurations(signing.Configurations{cfg})
175+
legacyAccountIdentifier := fmt.Sprintf("account-%s-%s-%s", legacyConfigurations.Hash(), account.coin.Code(), cfg.ScriptType())
176+
legacyAccountIdentifiers = append(
177+
legacyAccountIdentifiers,
178+
legacyAccountIdentifier,
179+
)
180+
}
181+
default:
182+
legacyAccountIdentifiers = []string{
183+
fmt.Sprintf("account-%s-%s", legacyConfigurations[0].Hash(), account.coin.Code()),
184+
}
185+
}
186+
for _, identifier := range legacyAccountIdentifiers {
187+
notesFile := path.Join(account.config.NotesFolder, fmt.Sprintf("%s.json", identifier))
188+
if _, err := os.Stat(notesFile); os.IsNotExist(err) {
189+
continue // skip nonexistent legacy notes file
190+
}
191+
192+
legacyNotes, err := notes.LoadNotes(path.Join(
193+
account.config.NotesFolder,
194+
fmt.Sprintf("%s.json", identifier),
195+
))
196+
if err != nil {
197+
return err
198+
}
199+
account.notes = append(account.notes, legacyNotes)
200+
}
201+
202+
return nil
148203
}
149204

150205
// ProposeTxNote implements accounts.Account.
@@ -168,14 +223,33 @@ func (account *BaseAccount) GetAndClearProposedTxNote() string {
168223

169224
// SetTxNote implements accounts.Account.
170225
func (account *BaseAccount) SetTxNote(txID string, note string) error {
171-
if err := account.notes.SetTxNote(txID, note); err != nil {
226+
// The notes slice is guaranteed to have at least one element by BaseAccount.Initialize.
227+
if err := account.notes[0].SetTxNote(txID, note); err != nil {
172228
return err
173229
}
230+
// Delete the notes in legacy files. Don't really care if it fails.
231+
for i, notes := range account.notes[1:] {
232+
if err := notes.SetTxNote(txID, ""); err != nil {
233+
account.log.WithError(err).Errorf("Can't delete a note from a legacy file idx=%d", i)
234+
}
235+
}
174236
// Prompt refresh.
175237
account.config.OnEvent(EventStatusChanged)
176238
return nil
177239
}
178240

241+
// TxNote fetches a note for a transcation. Returns the empty string if no note was found.
242+
func (account *BaseAccount) TxNote(txID string) string {
243+
// Take the first note we can find. The first slice element is the regular location of notes,
244+
// the other elements lookup notes in legacy locations, so they are not lost when upgrading.
245+
for _, notes := range account.notes {
246+
if note := notes.TxNote(txID); note != "" {
247+
return note
248+
}
249+
}
250+
return ""
251+
}
252+
179253
// ExportCSV implements accounts.Account.
180254
func (account *BaseAccount) ExportCSV(w io.Writer, transactions []*TransactionData) error {
181255
writer := csv.NewWriter(w)
@@ -221,7 +295,7 @@ func (account *BaseAccount) ExportCSV(w io.Writer, transactions []*TransactionDa
221295
feeString,
222296
addressAndAmount.Address,
223297
transaction.TxID,
224-
account.Notes().TxNote(transaction.InternalID),
298+
account.TxNote(transaction.InternalID),
225299
})
226300
if err != nil {
227301
return errp.WithStack(err)

backend/accounts/baseaccount_test.go

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@ package accounts
1717
import (
1818
"bytes"
1919
"errors"
20+
"io/ioutil"
21+
"path"
2022
"testing"
2123
"time"
2224

25+
"github.com/btcsuite/btcutil/hdkeychain"
2326
"github.com/digitalbitbox/bitbox-wallet-app/backend/coins/coin"
2427
"github.com/digitalbitbox/bitbox-wallet-app/backend/coins/coin/mocks"
28+
"github.com/digitalbitbox/bitbox-wallet-app/backend/signing"
2529
"github.com/digitalbitbox/bitbox-wallet-app/util/logging"
2630
"github.com/digitalbitbox/bitbox-wallet-app/util/test"
2731
"github.com/stretchr/testify/require"
@@ -38,20 +42,65 @@ func TestBaseAccount(t *testing.T) {
3842
}
3943
panic("can't reach")
4044
}
45+
46+
derivationPath, err := signing.NewAbsoluteKeypath("m/84'/1'/0'")
47+
require.NoError(t, err)
48+
const xpub = "tpubDCxoQyC5JaGydxN3yprM6sgqgu65LruN3JBm1fnSmGxXR3AcuNwr" +
49+
"E7J2CVaCvuLPJtJNySjshNsYbR96Y7yfEdcywYqWubzUQLVGh2b4mF9"
50+
extendedPublicKey, err := hdkeychain.NewKeyFromString(xpub)
51+
require.NoError(t, err)
52+
4153
const accountIdentifier = "test-account-identifier"
4254
cfg := &AccountConfig{
43-
Code: "test",
44-
Name: "Test",
45-
DBFolder: test.TstTempDir("baseaccount_test_dbfolder"),
46-
NotesFolder: test.TstTempDir("baseaccount_test_notesfolder"),
47-
Keystores: nil,
48-
OnEvent: func(event Event) { events <- event },
49-
RateUpdater: nil,
50-
SigningConfigurations: nil,
51-
GetNotifier: nil,
55+
Code: "test",
56+
Name: "Test",
57+
DBFolder: test.TstTempDir("baseaccount_test_dbfolder"),
58+
NotesFolder: test.TstTempDir("baseaccount_test_notesfolder"),
59+
Keystores: nil,
60+
OnEvent: func(event Event) { events <- event },
61+
RateUpdater: nil,
62+
SigningConfigurations: signing.Configurations{
63+
signing.NewBitcoinConfiguration(signing.ScriptTypeP2PKH, []byte{1, 2, 3, 4}, derivationPath, extendedPublicKey),
64+
signing.NewBitcoinConfiguration(signing.ScriptTypeP2WPKH, []byte{1, 2, 3, 4}, derivationPath, extendedPublicKey),
65+
signing.NewBitcoinConfiguration(signing.ScriptTypeP2WPKHP2SH, []byte{1, 2, 3, 4}, derivationPath, extendedPublicKey),
66+
},
67+
GetNotifier: nil,
5268
}
69+
// The long ID in the filename is the legacy hash of the configurations above (unified and split).
70+
// This tests notes migration from v4.27.0 to v4.28.0.
71+
require.NoError(t,
72+
ioutil.WriteFile(
73+
path.Join(cfg.NotesFolder, "account-54b4597c3a5c48177ef2b12c97e0cb30b6fef0b431e7821675b17704c330ce5d-tbtc.json"),
74+
[]byte(`{"transactions": { "legacy-1": "legacy note in unified account" }}`),
75+
0666,
76+
),
77+
)
78+
require.NoError(t,
79+
ioutil.WriteFile(
80+
path.Join(cfg.NotesFolder, "account-989b84ec36f0b84e7926f9f5715e4b59a0592993b1fa4b70836addbcb0cb6e09-tbtc-p2pkh.json"),
81+
[]byte(`{"transactions": { "legacy-2": "legacy note in split account, p2pkh" }}`),
82+
0666,
83+
),
84+
)
85+
require.NoError(t,
86+
ioutil.WriteFile(
87+
path.Join(cfg.NotesFolder, "account-e60b99507ba983d522f15932dbe1214e99de13c56e2bea75ed9c285f7c013117-tbtc-p2wpkh.json"),
88+
[]byte(`{"transactions": { "legacy-3": "legacy note in split account, p2wpkh" }}`),
89+
0666,
90+
),
91+
)
92+
require.NoError(t,
93+
ioutil.WriteFile(
94+
path.Join(cfg.NotesFolder, "account-9e779e0d49e77236f0769e0bab2fd656958d3fd023dce2388525ee66fead88bb-tbtc-p2wpkh-p2sh.json"),
95+
[]byte(`{"transactions": { "legacy-4": "legacy note in split account, p2wpkh-p2sh" }}`),
96+
0666,
97+
),
98+
)
5399

54100
mockCoin := &mocks.CoinMock{
101+
CodeFunc: func() coin.Code {
102+
return coin.CodeTBTC
103+
},
55104
SmallestUnitFunc: func() string {
56105
return "satoshi"
57106
},
@@ -103,12 +152,26 @@ func TestBaseAccount(t *testing.T) {
103152
// Was cleared by the previous call.
104153
require.Equal(t, "", account.GetAndClearProposedTxNote())
105154

106-
notes := account.Notes()
107-
require.NotNil(t, notes)
108-
109155
require.NoError(t, account.SetTxNote("test-tx-id", "another test note"))
110156
require.Equal(t, EventStatusChanged, checkEvent())
111-
require.Equal(t, "another test note", notes.TxNote("test-tx-id"))
157+
require.Equal(t, "another test note", account.TxNote("test-tx-id"))
158+
159+
// Test notes migration from v4.27.0 to v4.28.0
160+
require.Equal(t, "legacy note in unified account", account.TxNote("legacy-1"))
161+
require.Equal(t, "legacy note in split account, p2pkh", account.TxNote("legacy-2"))
162+
require.Equal(t, "legacy note in split account, p2wpkh", account.TxNote("legacy-3"))
163+
require.Equal(t, "legacy note in split account, p2wpkh-p2sh", account.TxNote("legacy-4"))
164+
// Setting a note sets it in the main notes file, and wipes it out in legacy note files.
165+
require.NoError(t, account.SetTxNote("legacy-1", "updated legacy note"))
166+
require.Equal(t, "updated legacy note", account.TxNote("legacy-1"))
167+
contents, err := ioutil.ReadFile(path.Join(cfg.NotesFolder, "account-54b4597c3a5c48177ef2b12c97e0cb30b6fef0b431e7821675b17704c330ce5d-tbtc.json"))
168+
require.NoError(t, err)
169+
require.JSONEq(t, `{"transactions":{}}`, string(contents))
170+
171+
// Test that the notes were persisted under the right file name with the right contents.
172+
contents, err = ioutil.ReadFile(path.Join(cfg.NotesFolder, "test-account-identifier.json"))
173+
require.NoError(t, err)
174+
require.JSONEq(t, `{"transactions":{"legacy-1": "updated legacy note", "test-tx-id": "another test note"}}`, string(contents))
112175
})
113176

114177
t.Run("exportCSV", func(t *testing.T) {
@@ -122,7 +185,7 @@ func TestBaseAccount(t *testing.T) {
122185

123186
require.Equal(t, header, export(nil))
124187

125-
require.NoError(t, account.notes.SetTxNote("some-internal-tx-id", "some note, with a comma"))
188+
require.NoError(t, account.SetTxNote("some-internal-tx-id", "some note, with a comma"))
126189
fee := coin.NewAmountFromInt64(101)
127190
timestamp := time.Date(2020, 2, 30, 16, 44, 20, 0, time.UTC)
128191
require.Equal(t,

backend/coins/btc/handlers/handlers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func (handlers *Handlers) getAccountTransactions(_ *http.Request) (interface{},
175175
Fee: feeString,
176176
Time: formattedTime,
177177
Addresses: addresses,
178-
Note: handlers.account.Notes().TxNote(txInfo.InternalID),
178+
Note: handlers.account.TxNote(txInfo.InternalID),
179179
}
180180
switch handlers.account.Coin().(type) {
181181
case *btc.Coin:

0 commit comments

Comments
 (0)