create random accounts in memory for onboarding (#1464)
* add account onboarding struct * add onboarding to account manager * allow resetting onboarding * add onboarding functions to lib and mobile * fix lint warnings * update mnemonic test * remove unused fmt * reset onboarding before selecting account * expose ResetOnboaring to lib and mobile * refactoring * add comment * update StartOnboarding function * remove unused var * update VERSION * fix returned accounts slice
This commit is contained in:
parent
e28d4ef1a3
commit
dd17860302
|
@ -26,6 +26,8 @@ var (
|
|||
ErrAccountToKeyMappingFailure = errors.New("cannot retrieve a valid key for a given account")
|
||||
ErrNoAccountSelected = errors.New("no account has been selected, please login")
|
||||
ErrInvalidMasterKeyCreated = errors.New("can not create master extended key")
|
||||
ErrOnboardingNotStarted = errors.New("onboarding must be started before choosing an account")
|
||||
ErrOnboardingAccountNotFound = errors.New("cannot find onboarding account with the given id")
|
||||
)
|
||||
|
||||
// GethServiceProvider provides required geth services.
|
||||
|
@ -38,7 +40,10 @@ type GethServiceProvider interface {
|
|||
type Manager struct {
|
||||
geth GethServiceProvider
|
||||
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
|
||||
onboarding *Onboarding
|
||||
|
||||
selectedWalletAccount *SelectedExtKey // account that was processed during the last call to SelectAccount()
|
||||
selectedChatAccount *SelectedExtKey // account that was processed during the last call to SelectAccount()
|
||||
}
|
||||
|
@ -365,6 +370,55 @@ func (m *Manager) Accounts() ([]gethcommon.Address, error) {
|
|||
return filtered, nil
|
||||
}
|
||||
|
||||
// StartOnboarding starts the onboarding process generating accountsCount accounts and returns a slice of OnboardingAccount.
|
||||
func (m *Manager) StartOnboarding(accountsCount, mnemonicPhraseLength int) ([]*OnboardingAccount, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
onboarding, err := NewOnboarding(accountsCount, mnemonicPhraseLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.onboarding = onboarding
|
||||
|
||||
return m.onboarding.Accounts(), nil
|
||||
}
|
||||
|
||||
// RemoveOnboarding reset the current onboarding struct setting it to nil and deleting the accounts from memory.
|
||||
func (m *Manager) RemoveOnboarding() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.onboarding = nil
|
||||
}
|
||||
|
||||
// ImportOnboardingAccount imports the account specified by id and encrypts it with password.
|
||||
func (m *Manager) ImportOnboardingAccount(id string, password string) (Info, string, error) {
|
||||
var info Info
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.onboarding == nil {
|
||||
return info, "", ErrOnboardingNotStarted
|
||||
}
|
||||
|
||||
acc, err := m.onboarding.Account(id)
|
||||
if err != nil {
|
||||
return info, "", err
|
||||
}
|
||||
|
||||
info, err = m.RecoverAccount(password, acc.mnemonic)
|
||||
if err != nil {
|
||||
return info, "", err
|
||||
}
|
||||
|
||||
m.onboarding = nil
|
||||
|
||||
return info, acc.mnemonic, nil
|
||||
}
|
||||
|
||||
// refreshSelectedWalletAccount re-populates list of sub-accounts of the currently selected account (if any)
|
||||
func (m *Manager) refreshSelectedWalletAccount() {
|
||||
if m.selectedWalletAccount == nil {
|
||||
|
|
|
@ -224,6 +224,46 @@ func (s *ManagerTestSuite) TestRecoverAccount() {
|
|||
s.Equal(errKeyStore, err)
|
||||
}
|
||||
|
||||
func (s *ManagerTestSuite) TestOnboarding() {
|
||||
// try to choose an account before starting onboarding
|
||||
_, _, err := s.accManager.ImportOnboardingAccount("test-id", "test-password")
|
||||
s.Equal(ErrOnboardingNotStarted, err)
|
||||
|
||||
// generates 5 random accounts
|
||||
count := 5
|
||||
accounts, err := s.accManager.StartOnboarding(count, 24)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(count, len(accounts))
|
||||
|
||||
// try to choose an account with an undefined id
|
||||
_, _, err = s.accManager.ImportOnboardingAccount("test-id", "test-password")
|
||||
s.Equal(ErrOnboardingAccountNotFound, err)
|
||||
|
||||
// choose one account and encrypt it with password
|
||||
password := "test-onboarding-account"
|
||||
account := accounts[0]
|
||||
s.gethServiceProvider.EXPECT().AccountKeyStore().Return(s.keyStore, nil)
|
||||
info, mnemonic, err := s.accManager.ImportOnboardingAccount(account.ID, password)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(account.Info, info)
|
||||
s.Equal(account.mnemonic, mnemonic)
|
||||
s.Nil(s.accManager.onboarding)
|
||||
|
||||
// try to decrypt it with password to check if it's been imported correctly
|
||||
s.gethServiceProvider.EXPECT().AccountKeyStore().Return(s.keyStore, nil)
|
||||
decAccount, _, err := s.accManager.AddressToDecryptedAccount(info.WalletAddress, password)
|
||||
s.Require().NoError(err)
|
||||
s.Equal(info.WalletAddress, decAccount.Address.Hex())
|
||||
|
||||
// try resetting onboarding
|
||||
_, err = s.accManager.StartOnboarding(count, 24)
|
||||
s.Require().NoError(err)
|
||||
s.NotNil(s.accManager.onboarding)
|
||||
|
||||
s.accManager.RemoveOnboarding()
|
||||
s.Nil(s.accManager.onboarding)
|
||||
}
|
||||
|
||||
func (s *ManagerTestSuite) TestSelectAccount() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/pborman/uuid"
|
||||
"github.com/status-im/status-go/extkeys"
|
||||
)
|
||||
|
||||
// ErrInvalidMnemonicPhraseLength is returned if the requested mnemonic length is invalid.
|
||||
// Valid lengths are 12, 15, 18, 21, and 24.
|
||||
var ErrInvalidMnemonicPhraseLength = errors.New("mnemonic phrase length; valid lengths are 12, 15, 18, 21, and 24")
|
||||
|
||||
// OnboardingAccount is returned during onboarding and contains its ID and the mnemonic to re-generate the same account Info keys.
|
||||
type OnboardingAccount struct {
|
||||
ID string `json:"id"`
|
||||
mnemonic string
|
||||
Info Info `json:"info"`
|
||||
}
|
||||
|
||||
// Onboarding is a struct contains a slice of OnboardingAccount.
|
||||
type Onboarding struct {
|
||||
accounts map[string]*OnboardingAccount
|
||||
}
|
||||
|
||||
// NewOnboarding returns a new onboarding struct generating n accounts.
|
||||
func NewOnboarding(n, mnemonicPhraseLength int) (*Onboarding, error) {
|
||||
onboarding := &Onboarding{
|
||||
accounts: make(map[string]*OnboardingAccount),
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
account, err := onboarding.generateAccount(mnemonicPhraseLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
onboarding.accounts[account.ID] = account
|
||||
}
|
||||
|
||||
return onboarding, nil
|
||||
}
|
||||
|
||||
// Accounts return the list of OnboardingAccount generated.
|
||||
func (o *Onboarding) Accounts() []*OnboardingAccount {
|
||||
accounts := make([]*OnboardingAccount, 0)
|
||||
for _, a := range o.accounts {
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
// Account returns an OnboardingAccount by id.
|
||||
func (o *Onboarding) Account(id string) (*OnboardingAccount, error) {
|
||||
account, ok := o.accounts[id]
|
||||
if !ok {
|
||||
return nil, ErrOnboardingAccountNotFound
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (o *Onboarding) generateAccount(mnemonicPhraseLength int) (*OnboardingAccount, error) {
|
||||
entropyStrength, err := mnemonicPhraseLengthToEntropyStrenght(mnemonicPhraseLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mnemonic := extkeys.NewMnemonic()
|
||||
mnemonicPhrase, err := mnemonic.MnemonicPhrase(entropyStrength, extkeys.EnglishLanguage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can not create mnemonic seed: %v", err)
|
||||
}
|
||||
|
||||
masterExtendedKey, err := extkeys.NewMaster(mnemonic.MnemonicSeed(mnemonicPhrase, ""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can not create master extended key: %v", err)
|
||||
}
|
||||
|
||||
walletAddress, walletPubKey, err := o.deriveAccount(masterExtendedKey, extkeys.KeyPurposeWallet, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := Info{
|
||||
WalletAddress: walletAddress,
|
||||
WalletPubKey: walletPubKey,
|
||||
ChatAddress: walletAddress,
|
||||
ChatPubKey: walletPubKey,
|
||||
}
|
||||
|
||||
uuid := uuid.NewRandom().String()
|
||||
|
||||
account := &OnboardingAccount{
|
||||
ID: uuid,
|
||||
mnemonic: mnemonicPhrase,
|
||||
Info: info,
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (o *Onboarding) deriveAccount(masterExtendedKey *extkeys.ExtendedKey, purpose extkeys.KeyPurpose, index uint32) (string, string, error) {
|
||||
extendedKey, err := masterExtendedKey.ChildForPurpose(purpose, index)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
privateKeyECDSA := extendedKey.ToECDSA()
|
||||
address := crypto.PubkeyToAddress(privateKeyECDSA.PublicKey)
|
||||
publicKeyHex := hexutil.Encode(crypto.FromECDSAPub(&privateKeyECDSA.PublicKey))
|
||||
|
||||
return address.Hex(), publicKeyHex, nil
|
||||
}
|
||||
|
||||
func mnemonicPhraseLengthToEntropyStrenght(length int) (extkeys.EntropyStrength, error) {
|
||||
if length < 12 || length > 24 || length%3 != 0 {
|
||||
return 0, ErrInvalidMnemonicPhraseLength
|
||||
}
|
||||
|
||||
bitsLength := length * 11
|
||||
checksumLength := bitsLength % 32
|
||||
|
||||
return extkeys.EntropyStrength(bitsLength - checksumLength), nil
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/status-im/status-go/extkeys"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMnemonicPhraseLengthToEntropyStrenght(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
phraseLength int
|
||||
expectedStrength extkeys.EntropyStrength
|
||||
expectedError error
|
||||
}{
|
||||
{12, 128, nil},
|
||||
{15, 160, nil},
|
||||
{18, 192, nil},
|
||||
{21, 224, nil},
|
||||
{24, 256, nil},
|
||||
// invalid
|
||||
{11, 0, ErrInvalidMnemonicPhraseLength},
|
||||
{14, 0, ErrInvalidMnemonicPhraseLength},
|
||||
{25, 0, ErrInvalidMnemonicPhraseLength},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
strength, err := mnemonicPhraseLengthToEntropyStrenght(s.phraseLength)
|
||||
assert.Equal(t, s.expectedError, err)
|
||||
assert.Equal(t, s.expectedStrength, strength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboarding(t *testing.T) {
|
||||
count := 2
|
||||
wordsCount := 24
|
||||
o, _ := NewOnboarding(count, wordsCount)
|
||||
assert.Equal(t, count, len(o.accounts))
|
||||
|
||||
for id, a := range o.accounts {
|
||||
words := strings.Split(a.mnemonic, " ")
|
||||
|
||||
assert.Equal(t, wordsCount, len(words))
|
||||
assert.NotEmpty(t, a.Info.WalletAddress)
|
||||
assert.NotEmpty(t, a.Info.WalletPubKey)
|
||||
assert.NotEmpty(t, a.Info.ChatAddress)
|
||||
assert.NotEmpty(t, a.Info.ChatPubKey)
|
||||
|
||||
retrieved, err := o.Account(id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, a, retrieved)
|
||||
}
|
||||
}
|
|
@ -493,6 +493,8 @@ func (b *StatusBackend) Logout() error {
|
|||
|
||||
// reSelectAccount selects previously selected account, often, after node restart.
|
||||
func (b *StatusBackend) reSelectAccount() error {
|
||||
b.AccountManager().RemoveOnboarding()
|
||||
|
||||
selectedChatAccount, err := b.AccountManager().SelectedChatAccount()
|
||||
if selectedChatAccount == nil || err == account.ErrNoAccountSelected {
|
||||
return nil
|
||||
|
@ -518,6 +520,8 @@ func (b *StatusBackend) SelectAccount(walletAddress, chatAddress, password strin
|
|||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.AccountManager().RemoveOnboarding()
|
||||
|
||||
err := b.accountManager.SelectAccount(walletAddress, chatAddress, password)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -40,11 +40,11 @@ const (
|
|||
totalAvailableLanguages
|
||||
)
|
||||
|
||||
type entropyStrength int
|
||||
type EntropyStrength int
|
||||
|
||||
// Valid entropy strengths
|
||||
const (
|
||||
EntropyStrength128 entropyStrength = 128 + 32*iota
|
||||
EntropyStrength128 EntropyStrength = 128 + 32*iota
|
||||
EntropyStrength160
|
||||
EntropyStrength192
|
||||
EntropyStrength224
|
||||
|
@ -129,7 +129,7 @@ func (m *Mnemonic) MnemonicSeed(mnemonic string, password string) []byte {
|
|||
}
|
||||
|
||||
// MnemonicPhrase returns a human readable seed for BIP32 Hierarchical Deterministic Wallets
|
||||
func (m *Mnemonic) MnemonicPhrase(strength entropyStrength, language Language) (string, error) {
|
||||
func (m *Mnemonic) MnemonicPhrase(strength EntropyStrength, language Language) (string, error) {
|
||||
wordList, err := m.WordList(language)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
|
@ -45,7 +45,7 @@ func TestMnemonicPhrase(t *testing.T) {
|
|||
mnemonic := NewMnemonic()
|
||||
|
||||
// test strength validation
|
||||
strengths := []entropyStrength{127, 129, 257}
|
||||
strengths := []EntropyStrength{127, 129, 257}
|
||||
for _, s := range strengths {
|
||||
_, err := mnemonic.MnemonicPhrase(s, EnglishLanguage)
|
||||
if err != ErrInvalidEntropyStrength {
|
||||
|
|
|
@ -234,6 +234,72 @@ func RecoverAccount(password, mnemonic *C.char) *C.char {
|
|||
return C.CString(string(outBytes))
|
||||
}
|
||||
|
||||
// StartOnboarding initialize the onboarding with n random accounts
|
||||
//export StartOnboarding
|
||||
func StartOnboarding(n, mnemonicPhraseLength C.int) *C.char {
|
||||
out := struct {
|
||||
Accounts []OnboardingAccount `json:"accounts"`
|
||||
Error string `json:"error"`
|
||||
}{
|
||||
Accounts: make([]OnboardingAccount, 0),
|
||||
}
|
||||
|
||||
accounts, err := statusBackend.AccountManager().StartOnboarding(int(n), int(mnemonicPhraseLength))
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
out.Error = err.Error()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
for _, account := range accounts {
|
||||
out.Accounts = append(out.Accounts, OnboardingAccount{
|
||||
ID: account.ID,
|
||||
Address: account.Info.WalletAddress,
|
||||
PubKey: account.Info.WalletPubKey,
|
||||
WalletAddress: account.Info.WalletAddress,
|
||||
WalletPubKey: account.Info.WalletPubKey,
|
||||
ChatAddress: account.Info.ChatAddress,
|
||||
ChatPubKey: account.Info.ChatPubKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outBytes, _ := json.Marshal(out)
|
||||
return C.CString(string(outBytes))
|
||||
}
|
||||
|
||||
// ImportOnboardingAccount re-creates and imports an account created during onboarding.
|
||||
//export ImportOnboardingAccount
|
||||
func ImportOnboardingAccount(id, password *C.char) *C.char {
|
||||
info, mnemonic, err := statusBackend.AccountManager().ImportOnboardingAccount(C.GoString(id), C.GoString(password))
|
||||
|
||||
errString := ""
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
errString = err.Error()
|
||||
}
|
||||
|
||||
out := AccountInfo{
|
||||
Address: info.WalletAddress,
|
||||
PubKey: info.WalletPubKey,
|
||||
WalletAddress: info.WalletAddress,
|
||||
WalletPubKey: info.WalletPubKey,
|
||||
ChatAddress: info.ChatAddress,
|
||||
ChatPubKey: info.ChatPubKey,
|
||||
Mnemonic: mnemonic,
|
||||
Error: errString,
|
||||
}
|
||||
outBytes, _ := json.Marshal(out)
|
||||
return C.CString(string(outBytes))
|
||||
}
|
||||
|
||||
// RemoveOnboarding resets the current onboarding removing from memory all the generated keys.
|
||||
//export RemoveOnboarding
|
||||
func RemoveOnboarding() {
|
||||
statusBackend.AccountManager().RemoveOnboarding()
|
||||
}
|
||||
|
||||
//VerifyAccountPassword verifies account password
|
||||
//export VerifyAccountPassword
|
||||
func VerifyAccountPassword(keyStoreDir, address, password *C.char) *C.char {
|
||||
|
|
11
lib/types.go
11
lib/types.go
|
@ -74,6 +74,17 @@ type AccountInfo struct {
|
|||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// OnboardingAccount represents accounts info generated for the onboarding.
|
||||
type OnboardingAccount struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"` // DEPRECATED
|
||||
PubKey string `json:"pubkey"` // DEPRECATED
|
||||
WalletAddress string `json:"walletAddress"`
|
||||
WalletPubKey string `json:"walletPubKey"`
|
||||
ChatAddress string `json:"chatAddress"`
|
||||
ChatPubKey string `json:"chatPubKey"`
|
||||
}
|
||||
|
||||
// SendDataNotificationResult is a JSON returned from notify message.
|
||||
type SendDataNotificationResult struct {
|
||||
Status bool `json:"status"`
|
||||
|
|
|
@ -238,6 +238,69 @@ func RecoverAccount(password, mnemonic string) string {
|
|||
return string(outBytes)
|
||||
}
|
||||
|
||||
// StartOnboarding initialize the onboarding with n random accounts
|
||||
func StartOnboarding(n, mnemonicPhraseLength int) string {
|
||||
out := struct {
|
||||
Accounts []OnboardingAccount `json:"accounts"`
|
||||
Error string `json:"error"`
|
||||
}{
|
||||
Accounts: make([]OnboardingAccount, 0),
|
||||
}
|
||||
|
||||
accounts, err := statusBackend.AccountManager().StartOnboarding(n, mnemonicPhraseLength)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
out.Error = err.Error()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
for _, account := range accounts {
|
||||
out.Accounts = append(out.Accounts, OnboardingAccount{
|
||||
ID: account.ID,
|
||||
Address: account.Info.WalletAddress,
|
||||
PubKey: account.Info.WalletPubKey,
|
||||
WalletAddress: account.Info.WalletAddress,
|
||||
WalletPubKey: account.Info.WalletPubKey,
|
||||
ChatAddress: account.Info.ChatAddress,
|
||||
ChatPubKey: account.Info.ChatPubKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outBytes, _ := json.Marshal(out)
|
||||
return string(outBytes)
|
||||
}
|
||||
|
||||
//ImportOnboardingAccount re-creates and imports an account created during onboarding.
|
||||
func ImportOnboardingAccount(id, password string) string {
|
||||
info, mnemonic, err := statusBackend.AccountManager().ImportOnboardingAccount(id, password)
|
||||
|
||||
errString := ""
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
errString = err.Error()
|
||||
}
|
||||
|
||||
out := AccountInfo{
|
||||
Address: info.WalletAddress,
|
||||
PubKey: info.WalletPubKey,
|
||||
WalletAddress: info.WalletAddress,
|
||||
WalletPubKey: info.WalletPubKey,
|
||||
ChatAddress: info.ChatAddress,
|
||||
ChatPubKey: info.ChatPubKey,
|
||||
Mnemonic: mnemonic,
|
||||
Error: errString,
|
||||
}
|
||||
outBytes, _ := json.Marshal(out)
|
||||
return string(outBytes)
|
||||
}
|
||||
|
||||
// RemoveOnboarding resets the current onboarding removing from memory all the generated keys.
|
||||
func RemoveOnboarding() {
|
||||
statusBackend.AccountManager().RemoveOnboarding()
|
||||
}
|
||||
|
||||
// VerifyAccountPassword verifies account password.
|
||||
func VerifyAccountPassword(keyStoreDir, address, password string) string {
|
||||
_, err := statusBackend.AccountManager().VerifyAccountPassword(keyStoreDir, address, password)
|
||||
|
|
|
@ -74,6 +74,17 @@ type AccountInfo struct {
|
|||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// OnboardingAccount represents accounts info generated for the onboarding.
|
||||
type OnboardingAccount struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"` // DEPRECATED
|
||||
PubKey string `json:"pubkey"` // DEPRECATED
|
||||
WalletAddress string `json:"walletAddress"`
|
||||
WalletPubKey string `json:"walletPubKey"`
|
||||
ChatAddress string `json:"chatAddress"`
|
||||
ChatPubKey string `json:"chatPubKey"`
|
||||
}
|
||||
|
||||
// NotifyResult is a JSON returned from notify message.
|
||||
type NotifyResult struct {
|
||||
Status bool `json:"status"`
|
||||
|
|
Loading…
Reference in New Issue