diff --git a/.gitignore b/.gitignore index a35a34d..13c29a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea dist/ -.DS_Store \ No newline at end of file +.DS_Store + +solana-vault.json \ No newline at end of file diff --git a/account.go b/account.go index 2dcbc74..4b51616 100644 --- a/account.go +++ b/account.go @@ -31,3 +31,22 @@ func AccountFromPrivateKeyBase58(privateKey string) (*Account, error) { func (a *Account) PublicKey() PublicKey { return a.PrivateKey.PublicKey() } + +type AccountMeta struct { + PublicKey PublicKey + IsSigner bool + IsWritable bool +} + +func (a *AccountMeta) less(act *AccountMeta) bool { + if a.IsSigner && !act.IsSigner { + return true + } else if !a.IsSigner && act.IsSigner { + return false + } + + if a.IsWritable { + return true + } + return false +} diff --git a/account_test.go b/account_test.go index e79c6a0..d393f09 100644 --- a/account_test.go +++ b/account_test.go @@ -3,6 +3,7 @@ package solana import ( "testing" + "github.com/magiconair/properties/assert" "github.com/stretchr/testify/require" ) @@ -18,3 +19,63 @@ func TestNewAccount(t *testing.T) { require.Equal(t, privateKey, a2.PrivateKey) require.Equal(t, public, a2.PublicKey()) } + +func Test_AccountMeta_less(t *testing.T) { + pkey := MustPublicKeyFromBase58("SysvarS1otHashes111111111111111111111111111") + tests := []struct { + name string + left *AccountMeta + right *AccountMeta + expect bool + }{ + { + name: "accounts are equal", + left: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: false}, + right: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: false}, + expect: false, + }, + { + name: "left is a signer, right is not a signer", + left: &AccountMeta{PublicKey: pkey, IsSigner: true, IsWritable: false}, + right: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: false}, + expect: true, + }, + { + name: "left is not a signer, right is a signer", + left: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: false}, + right: &AccountMeta{PublicKey: pkey, IsSigner: true, IsWritable: false}, + expect: false, + }, + { + name: "left is writable, right is not writable", + left: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: true}, + right: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: false}, + expect: true, + }, + { + name: "left is not writable, right is writable", + left: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: false}, + right: &AccountMeta{PublicKey: pkey, IsSigner: false, IsWritable: true}, + expect: false, + }, + { + name: "both are signers and left is writable, right is not writable", + left: &AccountMeta{PublicKey: pkey, IsSigner: true, IsWritable: true}, + right: &AccountMeta{PublicKey: pkey, IsSigner: true, IsWritable: false}, + expect: true, + }, + { + name: "both are signers andleft is not writable, right is writable", + left: &AccountMeta{PublicKey: pkey, IsSigner: true, IsWritable: false}, + right: &AccountMeta{PublicKey: pkey, IsSigner: true, IsWritable: true}, + expect: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expect, test.left.less(test.right)) + }) + } + +} diff --git a/cmd/slnc/cmd/spl.go b/cmd/slnc/cmd/token.go similarity index 85% rename from cmd/slnc/cmd/spl.go rename to cmd/slnc/cmd/token.go index 6f0ae45..94095b5 100644 --- a/cmd/slnc/cmd/spl.go +++ b/cmd/slnc/cmd/token.go @@ -16,11 +16,11 @@ package cmd import "github.com/spf13/cobra" -var splCmd = &cobra.Command{ - Use: "spl", - Short: "SPL Tokens related Instructions", +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Tokens related Instructions", } func init() { - RootCmd.AddCommand(splCmd) + RootCmd.AddCommand(tokenCmd) } diff --git a/cmd/slnc/cmd/spl_get.go b/cmd/slnc/cmd/token_get.go similarity index 86% rename from cmd/slnc/cmd/spl_get.go rename to cmd/slnc/cmd/token_get.go index 2e4c4ed..5e0192e 100644 --- a/cmd/slnc/cmd/spl_get.go +++ b/cmd/slnc/cmd/token_get.go @@ -18,11 +18,11 @@ import ( "github.com/spf13/cobra" ) -var splGetCmd = &cobra.Command{ +var tokenGetCmd = &cobra.Command{ Use: "get", - Short: "Retrieves SPL token objects", + Short: "Retrieves token objects", } func init() { - splCmd.AddCommand(splGetCmd) + tokenCmd.AddCommand(tokenGetCmd) } diff --git a/cmd/slnc/cmd/spl_get_mint.go b/cmd/slnc/cmd/token_get_mint.go similarity index 88% rename from cmd/slnc/cmd/spl_get_mint.go rename to cmd/slnc/cmd/token_get_mint.go index bc863d0..08c7429 100644 --- a/cmd/slnc/cmd/spl_get_mint.go +++ b/cmd/slnc/cmd/token_get_mint.go @@ -23,21 +23,21 @@ import ( "github.com/spf13/cobra" ) -var splGetMintCmd = &cobra.Command{ +var tokenGetMintCmd = &cobra.Command{ Use: "mint {mint_addr}", Short: "Retrieves mint information", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - mintAddr, err := solana.PublicKeyFromBase58(args[0]) + mintAddress, err := solana.PublicKeyFromBase58(args[0]) if err != nil { return fmt.Errorf("decoding mint addr: %w", err) } client := getClient() - acct, err := client.GetAccountInfo(ctx, mintAddr) + acct, err := client.GetAccountInfo(ctx, mintAddress) if err != nil { return fmt.Errorf("couldn't get account data: %w", err) } @@ -54,8 +54,6 @@ var splGetMintCmd = &cobra.Command{ var out []string - //out = append(out, fmt.Sprintf("Data length | %d", len(acct.Value.Data))) - out = append(out, fmt.Sprintf("Supply | %d", mint.Supply)) out = append(out, fmt.Sprintf("Decimals | %d", mint.Decimals)) @@ -78,5 +76,5 @@ var splGetMintCmd = &cobra.Command{ } func init() { - splGetCmd.AddCommand(splGetMintCmd) + tokenGetCmd.AddCommand(tokenGetMintCmd) } diff --git a/cmd/slnc/cmd/spl_list.go b/cmd/slnc/cmd/token_list.go similarity index 86% rename from cmd/slnc/cmd/spl_list.go rename to cmd/slnc/cmd/token_list.go index 8776495..b9deb47 100644 --- a/cmd/slnc/cmd/spl_list.go +++ b/cmd/slnc/cmd/token_list.go @@ -18,11 +18,11 @@ import ( "github.com/spf13/cobra" ) -var splListCmd = &cobra.Command{ +var tokenListCmd = &cobra.Command{ Use: "list", - Short: "Retrieves SPL token objects", + Short: "Retrieves token objects", } func init() { - splCmd.AddCommand(splListCmd) + tokenCmd.AddCommand(tokenListCmd) } diff --git a/cmd/slnc/cmd/spl_list_mints.go b/cmd/slnc/cmd/token_list_mints.go similarity index 56% rename from cmd/slnc/cmd/spl_list_mints.go rename to cmd/slnc/cmd/token_list_mints.go index c9a400e..0b8a87a 100644 --- a/cmd/slnc/cmd/spl_list_mints.go +++ b/cmd/slnc/cmd/token_list_mints.go @@ -16,35 +16,44 @@ package cmd import ( "fmt" + "strings" "github.com/dfuse-io/solana-go/programs/token" "github.com/ryanuber/columnize" "github.com/spf13/cobra" ) -var splListMintsCmd = &cobra.Command{ +var tokenListMintsCmd = &cobra.Command{ Use: "mints", Short: "Lists mints", RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement a different network argument, - // later. Ultimately, get on chain. We have a database here! + rpcCli := getClient() - mints, err := token.KnownMints("mainnet") + mints, err := token.FetchMints(cmd.Context(), rpcCli) if err != nil { - return fmt.Errorf("listing mints: %w", err) + return fmt.Errorf("unable to retrieve mints: %w", err) } - out := []string{"Symbol | Token address | Token name"} - + out := []string{"Supply | Decimals | Token Authority | Freeze Authority"} for _, m := range mints { - out = append(out, fmt.Sprintf("%s | %s | %s", m.TokenSymbol, m.MintAddress, m.TokenName)) + line := []string{fmt.Sprintf("%d", m.Supply), fmt.Sprintf("%d", m.Decimals)} + if m.MintAuthorityOption != 0 { + line = append(line, fmt.Sprintf("%s", m.MintAuthority)) + } else { + line = append(line, "No mint authority") + } + if m.FreezeAuthorityOption != 0 { + line = append(line, fmt.Sprintf("%s", m.FreezeAuthority)) + } else { + line = append(line, "No freeze authority") + } + out = append(out, strings.Join(line, " | ")) } fmt.Println(columnize.Format(out, nil)) - return nil }, } func init() { - splListCmd.AddCommand(splListMintsCmd) + tokenListCmd.AddCommand(tokenListMintsCmd) } diff --git a/cmd/slnc/cmd/token_register.go b/cmd/slnc/cmd/token_register.go index 5a968a2..4856c45 100644 --- a/cmd/slnc/cmd/token_register.go +++ b/cmd/slnc/cmd/token_register.go @@ -15,15 +15,10 @@ package cmd import ( - "bytes" "context" - "encoding/hex" "fmt" - "os" - "github.com/dfuse-io/solana-go/rpc" - - bin "github.com/dfuse-io/binary" + "github.com/spf13/viper" "github.com/dfuse-io/solana-go" "github.com/dfuse-io/solana-go/programs/system" @@ -36,6 +31,7 @@ var tokenRegisterCmd = &cobra.Command{ Short: "register meta data for a token", Args: cobra.ExactArgs(5), RunE: func(cmd *cobra.Command, args []string) error { + vault := mustGetWallet() client := getClient() tokenAddress, err := solana.PublicKeyFromBase58(args[1]) @@ -48,120 +44,56 @@ var tokenRegisterCmd = &cobra.Command{ symbol, err := tokenregistry.SymbolFromString(args[4]) errorCheck("invalid symbol", err) - tokenMetaDataAccount := solana.NewAccount() - - alexKey := solana.MustPublicKeyFromBase58("9hFtYBYmBJCVguRYs9pBTWKYAFoKfjYR7zBPpEkVsmD") - - //todo: remove - airDrop, err := client.RequestAirdrop(context.Background(), &alexKey, 10_000_000_000, rpc.CommitmentMax) - errorCheck("air drop", err) - fmt.Println("air drop hash:", airDrop) - - tokenRegistryProgramIDAddress := tokenregistry.ProgramID() - keys := []solana.PublicKey{ - alexKey, - tokenMetaDataAccount.PublicKey(), - tokenRegistryProgramIDAddress, - tokenAddress, - system.PROGRAM_ID, - } - alexKeyIndex := uint8(0) - tokenMetaDataAddressIndex := uint8(1) - tokenRegistryProgramAccountIndex := uint8(2) - tokenAddressIndex := uint8(3) - systemIDIndex := uint8(4) - - size := 145 - lamport, err := client.GetMinimumBalanceForRentExemption(context.Background(), size) - errorCheck("get minimum balance for rent exemption ", err) - - fmt.Println("minimum lamport for rent exemption:", lamport) - - from := alexKeyIndex - to := tokenMetaDataAddressIndex - metaDataAccountCreationInstruction, err := system.NewCreateAccount( - bin.Uint64(lamport), bin.Uint64(size), tokenRegistryProgramIDAddress, systemIDIndex, from, to, - ) - errorCheck("new create account instruction", err) - - buf := new(bytes.Buffer) - if err := bin.NewEncoder(buf).Encode(metaDataAccountCreationInstruction); err != nil { - panic(err) - } - fmt.Println("create account instruction hex:", hex.EncodeToString(buf.Bytes())) - - programIdIndex := tokenRegistryProgramAccountIndex - mintMetaIdx := tokenMetaDataAddressIndex - ownerIdx := alexKeyIndex - tokenIdx := tokenAddressIndex - - /// 0. `[writable]` The register data's account to initialize - /// 1. `[signer]` The registry's owner - /// 2. `[]` The mint address to link with this registration - registerToken, err := tokenregistry.NewRegisterToken(logo, name, symbol, programIdIndex, mintMetaIdx, ownerIdx, tokenIdx) - errorCheck("new register token instruction", err) - - _ = registerToken - - instructions := []solana.CompiledInstruction{ - *metaDataAccountCreationInstruction, - *registerToken, + pkeyStr := viper.GetString("token-regiser-cmd-registrar") + if pkeyStr == "" { + fmt.Errorf("unable to continue without a specified registrar") } - blockHashResult, err := client.GetRecentBlockhash(context.Background(), rpc.CommitmentMax) - errorCheck("get block recent block hash", err) + registrarPubKey, err := solana.PublicKeyFromBase58(pkeyStr) + errorCheck(fmt.Sprintf("invalid registrar key %q", pkeyStr), err) - message := solana.Message{ - Header: solana.MessageHeader{ - NumRequiredSignatures: 2, - NumReadonlySignedAccounts: 0, - NumReadonlyunsignedAccounts: 2, - }, - AccountKeys: keys, - RecentBlockhash: blockHashResult.Value.Blockhash, - Instructions: instructions, - } - - buf = new(bytes.Buffer) - err = bin.NewEncoder(buf).Encode(message) - errorCheck("message encoding", err) - dataToSign := buf.Bytes() - fmt.Println("Data to sign:", buf) - - v := mustGetWallet() - var signature solana.Signature - var signed bool - for _, privateKey := range v.KeyBag { - if privateKey.PublicKey() == alexKey { - signature, err = privateKey.Sign(dataToSign) - errorCheck("signe message", err) - signed = true + found := false + for _, privateKey := range vault.KeyBag { + if privateKey.PublicKey() == registrarPubKey { + found = true } } - fmt.Println("signing completed") - - if !signed { - fmt.Println("unable to find matching private key for signing") - os.Exit(1) + if !found { + return fmt.Errorf("registrar key must be present in the vault to register a token") } - fmt.Println("signature:", signature.String()) + tokenMetaAccount := solana.NewAccount() - tokenMetaAccountSignature, err := tokenMetaDataAccount.PrivateKey.Sign(dataToSign) - errorCheck("tokenMetaAccountSignature", err) + lamport, err := client.GetMinimumBalanceForRentExemption(context.Background(), tokenregistry.TOKEN_META_SIZE) + errorCheck("get minimum balance for rent exemption ", err) - trx := &solana.Transaction{ - Signatures: []solana.Signature{signature, tokenMetaAccountSignature}, - Message: message, - } + tokenRegistryProgramID := tokenregistry.ProgramID() + + createAccountInstruction := system.NewCreateAccountInstruction(uint64(lamport), tokenregistry.TOKEN_META_SIZE, tokenRegistryProgramID, registrarPubKey, tokenMetaAccount.PublicKey()) + registerTokenInstruction := tokenregistry.NewRegisterTokenInstruction(logo, name, symbol, tokenMetaAccount.PublicKey(), registrarPubKey, tokenAddress) + + trx, err := solana.TransactionWithInstructions([]solana.TransactionInstruction{createAccountInstruction, registerTokenInstruction}, nil) + errorCheck("unable to craft transaction", err) + + _, err = trx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + for _, k := range vault.KeyBag { + if k.PublicKey() == key { + return &k + } + } + return nil + }) + + errorCheck("unable to sign transaction", err) + + trxHash, err := client.SendTransaction(cmd.Context(), trx) - trxHash, err := client.SendTransaction(context.Background(), trx) - //trxHash, err := client.SimulateTransaction(context.Background(), trx) fmt.Println("sent transaction hash:", trxHash, " error:", err) return nil }, } func init() { - splCmd.AddCommand(tokenRegisterCmd) + tokenCmd.AddCommand(tokenRegisterCmd) + tokenRegisterCmd.PersistentFlags().String("registrar", "9hFtYBYmBJCVguRYs9pBTWKYAFoKfjYR7zBPpEkVsmD", "The public key that will register the token") } diff --git a/cmd/slnc/cmd/token_registry.go b/cmd/slnc/cmd/token_registry.go new file mode 100644 index 0000000..21b9a95 --- /dev/null +++ b/cmd/slnc/cmd/token_registry.go @@ -0,0 +1,26 @@ +// Copyright 2020 dfuse Platform Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import "github.com/spf13/cobra" + +var tokenRegistryCmd = &cobra.Command{ + Use: "token-registry", + Short: "Token Registry related Instructions", +} + +func init() { + RootCmd.AddCommand(tokenRegistryCmd) +} diff --git a/cmd/slnc/cmd/spl_get_meta.go b/cmd/slnc/cmd/token_registry_get.go similarity index 63% rename from cmd/slnc/cmd/spl_get_meta.go rename to cmd/slnc/cmd/token_registry_get.go index 362e11d..08b4f6b 100644 --- a/cmd/slnc/cmd/spl_get_meta.go +++ b/cmd/slnc/cmd/token_registry_get.go @@ -15,44 +15,43 @@ package cmd import ( - "context" + "fmt" "os" - bin "github.com/dfuse-io/binary" + "github.com/dfuse-io/solana-go/rpc" + "github.com/dfuse-io/solana-go" "github.com/dfuse-io/solana-go/programs/tokenregistry" - _ "github.com/dfuse-io/solana-go/programs/tokenregistry" "github.com/dfuse-io/solana-go/text" "github.com/spf13/cobra" ) -var getTokenMetaCmd = &cobra.Command{ - Use: "meta {account}", - Short: "Retrieve token meta for a specific account", +var tokenRegistryGetCmd = &cobra.Command{ + Use: "get {mint-address}", + Short: "Retrieve token meta for a specific token meta account", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := getClient() - ctx := context.Background() address := args[0] pubKey, err := solana.PublicKeyFromBase58(address) errorCheck("public key", err) - accountInfo, err := client.GetAccountInfo(ctx, pubKey) - errorCheck("get account info", err) + t, err := tokenregistry.GetTokenRegistryEntry(cmd.Context(), client, pubKey) + if err != nil { + if err == rpc.ErrNotFound { + fmt.Printf("No token registry entry found for given mint %q", pubKey.String()) + return nil + } + return fmt.Errorf("unable to retrieve token registry entry for mint %q: %w", pubKey.String(), err) + } - var tm *tokenregistry.TokenMeta - err = bin.NewDecoder(accountInfo.Value.Data).Decode(&tm) - errorCheck("decode", err) - - err = text.NewEncoder(os.Stdout).Encode(tm, nil) + err = text.NewEncoder(os.Stdout).Encode(t, nil) errorCheck("textEncoding", err) - - //fmt.Println("raw data", hex.EncodeToString(accountInfo.Value.Data)) return nil }, } func init() { - splGetCmd.AddCommand(getTokenMetaCmd) + tokenRegistryCmd.AddCommand(tokenRegistryGetCmd) } diff --git a/cmd/slnc/cmd/token_registry_list.go b/cmd/slnc/cmd/token_registry_list.go new file mode 100644 index 0000000..5c1a912 --- /dev/null +++ b/cmd/slnc/cmd/token_registry_list.go @@ -0,0 +1,66 @@ +// Copyright 2020 dfuse Platform Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "strings" + + "github.com/ryanuber/columnize" + + "github.com/dfuse-io/solana-go/programs/tokenregistry" + _ "github.com/dfuse-io/solana-go/programs/tokenregistry" + "github.com/spf13/cobra" +) + +var tokenRegistryListCmd = &cobra.Command{ + Use: "list", + Short: "Retrieve token register entries", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + client := getClient() + + entries, err := tokenregistry.GetEntries(cmd.Context(), client) + if err != nil { + return fmt.Errorf("unable to retrieve entries: %w", err) + } + + out := []string{"Is Initialized | Mint Address | Registration Authority | Logo | Name | Symbol"} + + for _, e := range entries { + initalized := "false" + if e.IsInitialized { + initalized = "true" + } + + line := []string{ + initalized, + e.MintAddress.String(), + e.RegistrationAuthority.String(), + e.Logo.String(), + e.Name.String(), + e.Symbol.String(), + } + out = append(out, strings.Join(line, " | ")) + } + + fmt.Println(columnize.Format(out, nil)) + return nil + }, +} + +func init() { + tokenRegistryCmd.AddCommand(tokenRegistryListCmd) +} diff --git a/cmd/slnc/cmd/spl_transfer.go b/cmd/slnc/cmd/token_transfer.go similarity index 88% rename from cmd/slnc/cmd/spl_transfer.go rename to cmd/slnc/cmd/token_transfer.go index 718a81c..4d2a415 100644 --- a/cmd/slnc/cmd/spl_transfer.go +++ b/cmd/slnc/cmd/token_transfer.go @@ -21,9 +21,9 @@ import ( "github.com/spf13/cobra" ) -var splTransferCmd = &cobra.Command{ +var tokenTransferCmd = &cobra.Command{ Use: "transfer {from} {to} {amount}", - Short: "Create and sign an SPL transfer transaction", + Short: "Create and sign a token transfer transaction", Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { client := getClient() @@ -43,5 +43,5 @@ var splTransferCmd = &cobra.Command{ } func init() { - splCmd.AddCommand(splTransferCmd) + tokenCmd.AddCommand(tokenTransferCmd) } diff --git a/go.mod b/go.mod index 0d323af..decb88f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gorilla/rpc v1.2.0 github.com/gorilla/websocket v1.4.2 github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/magiconair/properties v1.8.1 github.com/mr-tron/base58 v1.2.0 github.com/onsi/gomega v1.10.1 // indirect github.com/pkg/errors v0.9.1 diff --git a/programs/system/instructions.go b/programs/system/instructions.go index f02a0ae..e2566fe 100644 --- a/programs/system/instructions.go +++ b/programs/system/instructions.go @@ -54,10 +54,53 @@ func DecodeInstruction(accounts []*solana.AccountMeta, compiledInstruction *sola return inst, nil } +func NewCreateAccountInstruction(lamports uint64, space uint64, owner, from, to solana.PublicKey) *Instruction { + return &Instruction{ + BaseVariant: bin.BaseVariant{ + TypeID: 0, + Impl: &CreateAccount{ + Lamports: bin.Uint64(lamports), + Space: bin.Uint64(space), + Owner: owner, + Accounts: &CreateAccountAccounts{ + From: &solana.AccountMeta{PublicKey: from, IsSigner: true, IsWritable: true}, + New: &solana.AccountMeta{PublicKey: to, IsSigner: true, IsWritable: true}, + }, + }, + }, + } +} + type Instruction struct { bin.BaseVariant } +func (i *Instruction) Accounts() (out []*solana.AccountMeta) { + switch i.TypeID { + case 0: + accounts := i.Impl.(*CreateAccount).Accounts + out = []*solana.AccountMeta{accounts.From, accounts.New} + case 1: + // no account here + case 2: + accounts := i.Impl.(*Transfer).Accounts + out = []*solana.AccountMeta{accounts.From, accounts.To} + } + return +} + +func (i *Instruction) ProgramID() solana.PublicKey { + return PROGRAM_ID +} + +func (i *Instruction) Data() ([]byte, error) { + buf := new(bytes.Buffer) + if err := bin.NewEncoder(buf).Encode(i); err != nil { + return nil, fmt.Errorf("unable to encode instruction: %w", err) + } + return buf.Bytes(), nil +} + func (i *Instruction) TextEncode(encoder *text.Encoder, option *text.Option) error { return encoder.Encode(i.Impl, option) } @@ -100,32 +143,6 @@ func (i *CreateAccount) SetAccounts(accounts []*solana.AccountMeta, accountIndex return nil } -func NewCreateAccount(lamports bin.Uint64, space bin.Uint64, owner solana.PublicKey, programIdIndex uint8, fromAccountIdx uint8, toAccountIdx uint8) (*solana.CompiledInstruction, error) { - instruction := &Instruction{ - BaseVariant: bin.BaseVariant{ - TypeID: 0, - Impl: &CreateAccount{ - Lamports: lamports, - Space: space, - Owner: owner, - }, - }, - } - buf := new(bytes.Buffer) - if err := bin.NewEncoder(buf).Encode(instruction); err != nil { - return nil, fmt.Errorf("new create account: encode: %w", err) - } - - data := buf.Bytes() - return &solana.CompiledInstruction{ - ProgramIDIndex: programIdIndex, - AccountCount: 2, - Accounts: []uint8{fromAccountIdx, toAccountIdx}, - DataLength: bin.Varuint16(len(data)), - Data: data, - }, nil -} - type Assign struct { // prefixed with byte 0x01 Owner solana.PublicKey diff --git a/programs/token/rpc.go b/programs/token/rpc.go index 064a984..c406651 100644 --- a/programs/token/rpc.go +++ b/programs/token/rpc.go @@ -27,6 +27,38 @@ import ( //go:generate rice embed-go +func FetchMints(ctx context.Context, rpcCli *rpc.Client) (out []*Mint, err error) { + resp, err := rpcCli.GetProgramAccounts( + ctx, + TOKEN_PROGRAM_ID, + &rpc.GetProgramAccountsOpts{ + Filters: []rpc.RPCFilter{ + { + DataSize: MINT_SIZE, + }, + }, + }, + ) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("resp empty... program account not found") + } + + for _, keyedAcct := range resp { + acct := keyedAcct.Account + + m, err := DecodeMint(acct.Data) + if err != nil { + return nil, fmt.Errorf("unable to decode mint %q: %w", acct.Owner.String(), err) + } + out = append(out, m) + + } + return +} + func KnownMints(network string) ([]*MintMeta, error) { box := rice.MustFindBox("mints-data").MustBytes(network + "-tokens.json") if box == nil { diff --git a/programs/token/types.go b/programs/token/types.go index 563ca71..820965b 100644 --- a/programs/token/types.go +++ b/programs/token/types.go @@ -37,6 +37,8 @@ type Multisig struct { Signers [11]solana.PublicKey } +const MINT_SIZE = 82 + type Mint struct { MintAuthorityOption uint32 MintAuthority solana.PublicKey @@ -50,7 +52,7 @@ type Mint struct { func DecodeMint(in []byte) (*Mint, error) { var m *Mint decoder := bin.NewDecoder(in) - err := decoder.Decode(m) + err := decoder.Decode(&m) if err != nil { return nil, fmt.Errorf("unpack: %w", err) } diff --git a/programs/tokenregistry/instruction.go b/programs/tokenregistry/instruction.go index 2592e27..4066049 100644 --- a/programs/tokenregistry/instruction.go +++ b/programs/tokenregistry/instruction.go @@ -39,10 +39,49 @@ func DecodeInstruction(accounts []*solana.AccountMeta, compiledInstruction *sola return &inst, nil } +func NewRegisterTokenInstruction(logo Logo, name Name, symbol Symbol, tokenMetaKey, ownerKey, tokenKey solana.PublicKey) *Instruction { + return &Instruction{ + BaseVariant: bin.BaseVariant{ + TypeID: 0, + Impl: &RegisterToken{ + Logo: logo, + Name: name, + Symbol: symbol, + Accounts: &RegisterTokenAccounts{ + TokenMeta: &solana.AccountMeta{tokenMetaKey, false, true}, + Owner: &solana.AccountMeta{ownerKey, true, false}, + Token: &solana.AccountMeta{tokenKey, false, false}, + }, + }, + }, + } +} + type Instruction struct { bin.BaseVariant } +func (i *Instruction) Accounts() (out []*solana.AccountMeta) { + switch i.TypeID { + case 0: + accounts := i.Impl.(*RegisterToken).Accounts + out = []*solana.AccountMeta{accounts.TokenMeta, accounts.Owner, accounts.Token} + } + return +} + +func (i *Instruction) ProgramID() solana.PublicKey { + return ProgramID() +} + +func (i *Instruction) Data() ([]byte, error) { + buf := new(bytes.Buffer) + if err := bin.NewEncoder(buf).Encode(i); err != nil { + return nil, fmt.Errorf("unable to encode instruction: %w", err) + } + return buf.Bytes(), nil +} + var InstructionDefVariant = bin.NewVariantDefinition(bin.Uint32TypeIDEncoding, []bin.VariantType{ {"register_token", (*RegisterToken)(nil)}, }) @@ -64,9 +103,9 @@ func (i *Instruction) MarshalBinary(encoder *bin.Encoder) error { } type RegisterTokenAccounts struct { - MintMeta *solana.AccountMeta `text:"linear,notype"` - Owner *solana.AccountMeta `text:"linear,notype"` - Token *solana.AccountMeta `text:"linear,notype"` + TokenMeta *solana.AccountMeta `text:"linear,notype"` + Owner *solana.AccountMeta `text:"linear,notype"` + Token *solana.AccountMeta `text:"linear,notype"` } type RegisterToken struct { @@ -76,46 +115,14 @@ type RegisterToken struct { Accounts *RegisterTokenAccounts `bin:"-"` } -func NewRegisterToken(logo Logo, name Name, symbol Symbol, programIdIndex uint8, mintMetaIdx uint8, ownerIdx uint8, tokenIdx uint8) (*solana.CompiledInstruction, error) { - instruction := &Instruction{ - BaseVariant: bin.BaseVariant{ - TypeID: 0, - Impl: &RegisterToken{ - Logo: logo, - Name: name, - Symbol: symbol, - }, - }, - } - - buf := new(bytes.Buffer) - if err := bin.NewEncoder(buf).Encode(instruction); err != nil { - return nil, fmt.Errorf("new register token: encode: %w", err) - } - data := buf.Bytes() - - /// 0. `[writable]` The register data's account to initialize - /// 1. `[signer]` The registry's owner - /// 2. `[]` The mint address to link with this registration - - return &solana.CompiledInstruction{ - ProgramIDIndex: programIdIndex, - AccountCount: 3, - Accounts: []uint8{mintMetaIdx, ownerIdx, tokenIdx}, - DataLength: bin.Varuint16(len(data)), - Data: data, - }, nil - -} - func (i *RegisterToken) SetAccounts(accounts []*solana.AccountMeta, instructionActIdx []uint8) error { if len(instructionActIdx) < 9 { return fmt.Errorf("insuficient account") } i.Accounts = &RegisterTokenAccounts{ - MintMeta: accounts[instructionActIdx[0]], - Owner: accounts[instructionActIdx[1]], - Token: accounts[instructionActIdx[2]], + TokenMeta: accounts[instructionActIdx[0]], + Owner: accounts[instructionActIdx[1]], + Token: accounts[instructionActIdx[2]], } return nil diff --git a/programs/tokenregistry/rpc.go b/programs/tokenregistry/rpc.go new file mode 100644 index 0000000..d495422 --- /dev/null +++ b/programs/tokenregistry/rpc.go @@ -0,0 +1,72 @@ +package tokenregistry + +import ( + "context" + "fmt" + + "github.com/dfuse-io/solana-go" + "github.com/dfuse-io/solana-go/rpc" +) + +func GetTokenRegistryEntry(ctx context.Context, rpcCli *rpc.Client, mintAddress solana.PublicKey) (*TokenMeta, error) { + resp, err := rpcCli.GetProgramAccounts( + ctx, + ProgramID(), + &rpc.GetProgramAccountsOpts{ + Filters: []rpc.RPCFilter{ + { + Memcmp: &rpc.RPCFilterMemcmp{ + Offset: 5, + Bytes: mintAddress[:], // hackey to convert [32]byte to []byte + }, + }, + }, + }, + ) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("resp empty... cannot find account") + } + + for _, keyedAcct := range resp { + acct := keyedAcct.Account + t, err := DecodeTokenMeta(acct.Data) + if err != nil { + return nil, fmt.Errorf("unable to decode token meta %q: %w", acct.Owner.String(), err) + } + return t, nil + } + return nil, rpc.ErrNotFound +} + +func GetEntries(ctx context.Context, rpcCli *rpc.Client) (out []*TokenMeta, err error) { + resp, err := rpcCli.GetProgramAccounts( + ctx, + ProgramID(), + &rpc.GetProgramAccountsOpts{ + Filters: []rpc.RPCFilter{ + { + DataSize: TOKEN_META_SIZE, + }, + }, + }, + ) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("resp empty... cannot find accounts") + } + + for _, keyedAcct := range resp { + acct := keyedAcct.Account + t, err := DecodeTokenMeta(acct.Data) + if err != nil { + return nil, fmt.Errorf("unable to decode token meta %q: %w", acct.Owner.String(), err) + } + out = append(out, t) + } + return out, nil +} diff --git a/programs/tokenregistry/types.go b/programs/tokenregistry/types.go index 87527fe..ec66032 100644 --- a/programs/tokenregistry/types.go +++ b/programs/tokenregistry/types.go @@ -16,9 +16,13 @@ package tokenregistry import ( "fmt" + bin "github.com/dfuse-io/binary" + "github.com/dfuse-io/solana-go" ) +const TOKEN_META_SIZE = 145 + type TokenMeta struct { IsInitialized bool Reg [3]byte `text:"-"` @@ -30,6 +34,16 @@ type TokenMeta struct { Symbol Symbol } +func DecodeTokenMeta(in []byte) (*TokenMeta, error) { + var t *TokenMeta + decoder := bin.NewDecoder(in) + err := decoder.Decode(&t) + if err != nil { + return nil, fmt.Errorf("unpack: %w", err) + } + return t, nil +} + type Logo [32]byte func LogoFromString(logo string) (Logo, error) { diff --git a/transaction.go b/transaction.go new file mode 100644 index 0000000..e02f530 --- /dev/null +++ b/transaction.go @@ -0,0 +1,180 @@ +package solana + +import ( + "bytes" + "fmt" + "sort" + + bin "github.com/dfuse-io/binary" + + "go.uber.org/zap" +) + +type TransactionInstruction interface { + Accounts() []*AccountMeta // returns the list of accounts the instructions requires + ProgramID() PublicKey // the programID the instruction acts on + Data() ([]byte, error) // the binary encoded instructions +} + +type Options struct { + payer *PublicKey +} + +func TransactionWithInstructions(instructions []TransactionInstruction, opt *Options) (*Transaction, error) { + if len(instructions) == 0 { + return nil, fmt.Errorf("requires at-least one instruction to create a transaction") + } + + if opt == nil { + opt = &Options{} + } + + feePayer := opt.payer + if feePayer == nil { + for _, act := range instructions[0].Accounts() { + if act.IsSigner { + feePayer = &act.PublicKey + break + } + } + } + if feePayer == nil { + return nil, fmt.Errorf("cannot determine fee payer. You can ether pass the fee payer vai the 'TransactionWithInstructions' option parameter or it fallback to the first instruction's first signer") + } + + programIDs := map[string]bool{} + accounts := []*AccountMeta{} + for _, instruction := range instructions { + for _, key := range instruction.Accounts() { + accounts = append(accounts, key) + } + programIDs[instruction.ProgramID().String()] = true + } + + // Add programID to the account list + for programId, _ := range programIDs { + accounts = append(accounts, &AccountMeta{ + PublicKey: MustPublicKeyFromBase58(programId), + IsSigner: false, + IsWritable: false, + }) + } + + // Sort. Prioritizing first by signer, then by writable + sort.Slice(accounts, func(i, j int) bool { + return accounts[i].less(accounts[j]) + }) + + uniqAccountsMap := map[string]uint64{} + uniqAccounts := []*AccountMeta{} + for _, acc := range accounts { + if index, found := uniqAccountsMap[acc.PublicKey.String()]; found { + uniqAccounts[index].IsWritable = uniqAccounts[index].IsWritable || acc.IsWritable + continue + } + uniqAccounts = append(uniqAccounts, acc) + uniqAccountsMap[acc.PublicKey.String()] = uint64(len(uniqAccounts) - 1) + } + + zlog.Debug("unique account sorted", zap.Int("account_count", len(uniqAccounts))) + // Move fee payer to the front + feePayerIndex := -1 + for idx, acc := range uniqAccounts { + if acc.PublicKey.Equals(*feePayer) { + feePayerIndex = idx + } + } + zlog.Debug("current fee payer index", zap.Int("fee_payer_index", feePayerIndex)) + + accountCount := len(uniqAccounts) + if feePayerIndex < 0 { + // fee payer is not part of accounts we want to add it + accountCount++ + } + finalAccounts := make([]*AccountMeta, accountCount) + + itr := 1 + for idx, uniqAccount := range uniqAccounts { + if idx == feePayerIndex { + uniqAccount.IsSigner = true + uniqAccount.IsWritable = true + finalAccounts[0] = uniqAccount + continue + } + finalAccounts[itr] = uniqAccount + itr++ + } + + message := Message{ + AccountKeys: nil, + RecentBlockhash: PublicKey{}, + Instructions: nil, + } + accountKeyIndex := map[string]uint8{} + for idx, acc := range finalAccounts { + message.AccountKeys = append(message.AccountKeys, acc.PublicKey) + accountKeyIndex[acc.PublicKey.String()] = uint8(idx) + if acc.IsSigner { + message.Header.NumRequiredSignatures++ + if !acc.IsWritable { + message.Header.NumReadonlySignedAccounts++ + } + continue + } + + if !acc.IsWritable { + message.Header.NumReadonlyUnsignedAccounts++ + } + } + + for trxIdx, instruction := range instructions { + accounts = instruction.Accounts() + accountIndex := make([]uint8, len(accounts)) + for idx, acc := range accounts { + accountIndex[idx] = accountKeyIndex[acc.PublicKey.String()] + } + data, err := instruction.Data() + if err != nil { + return nil, fmt.Errorf("unable to encode instructions [%d]: %w", trxIdx, err) + } + message.Instructions = append(message.Instructions, CompiledInstruction{ + ProgramIDIndex: accountKeyIndex[instruction.ProgramID().String()], + AccountCount: bin.Varuint16(uint16(len(accountIndex))), + Accounts: accountIndex, + DataLength: bin.Varuint16(uint16(len(data))), + Data: data, + }) + } + + return &Transaction{ + Message: message, + }, nil + +} + +type privateKeyGetter func(key PublicKey) *PrivateKey + +func (t *Transaction) Sign(getter privateKeyGetter) (out []Signature, err error) { + buf := new(bytes.Buffer) + if err = bin.NewEncoder(buf).Encode(t.Message); err != nil { + return nil, fmt.Errorf("unable to encode message for signing: %w", err) + } + messageCnt := buf.Bytes() + + signerKeys := t.Message.signerKeys() + + for _, key := range signerKeys { + privateKey := getter(key) + if privateKey == nil { + return nil, fmt.Errorf("signer key %q not found. Ensure all the signer keys are in the vault", key.String()) + } + + s, err := privateKey.Sign(messageCnt) + if err != nil { + return nil, fmt.Errorf("failed to signed with key %q: %w", key.String(), err) + } + + t.Signatures = append(t.Signatures, s) + } + return t.Signatures, nil +} diff --git a/transaction_test.go b/transaction_test.go new file mode 100644 index 0000000..1563b6c --- /dev/null +++ b/transaction_test.go @@ -0,0 +1,85 @@ +package solana + +import ( + "testing" + + "github.com/magiconair/properties/assert" + "github.com/stretchr/testify/require" +) + +type testTransactionInstructions struct { + accounts []*AccountMeta + data []byte + programID PublicKey +} + +func (t *testTransactionInstructions) Accounts() []*AccountMeta { + return t.accounts +} + +func (t *testTransactionInstructions) ProgramID() PublicKey { + return t.programID +} + +func (t *testTransactionInstructions) Data() ([]byte, error) { + return t.data, nil +} + +func TestTransactionWithInstructions(t *testing.T) { + instructions := []TransactionInstruction{ + &testTransactionInstructions{ + accounts: []*AccountMeta{ + {PublicKey: MustPublicKeyFromBase58("A9QnpgfhCkmiBSjgBuWk76Wo3HxzxvDopUq9x6UUMmjn"), IsSigner: true, IsWritable: false}, + {PublicKey: MustPublicKeyFromBase58("9hFtYBYmBJCVguRYs9pBTWKYAFoKfjYR7zBPpEkVsmD"), IsSigner: true, IsWritable: true}, + }, + data: []byte{0xaa, 0xbb}, + programID: MustPublicKeyFromBase58("11111111111111111111111111111111"), + }, + &testTransactionInstructions{ + accounts: []*AccountMeta{ + {PublicKey: MustPublicKeyFromBase58("SysvarC1ock11111111111111111111111111111111"), IsSigner: false, IsWritable: false}, + {PublicKey: MustPublicKeyFromBase58("SysvarS1otHashes111111111111111111111111111"), IsSigner: false, IsWritable: true}, + {PublicKey: MustPublicKeyFromBase58("9hFtYBYmBJCVguRYs9pBTWKYAFoKfjYR7zBPpEkVsmD"), IsSigner: false, IsWritable: true}, + {PublicKey: MustPublicKeyFromBase58("6FzXPEhCJoBx7Zw3SN9qhekHemd6E2b8kVguitmVAngW"), IsSigner: true, IsWritable: false}, + }, + data: []byte{0xcc, 0xdd}, + programID: MustPublicKeyFromBase58("Vote111111111111111111111111111111111111111"), + }, + } + + trx, err := TransactionWithInstructions(instructions, nil) + require.NoError(t, err) + + assert.Equal(t, trx.Message.Header, MessageHeader{ + NumRequiredSignatures: 3, + NumReadonlySignedAccounts: 1, + NumReadonlyUnsignedAccounts: 3, + }) + + assert.Equal(t, trx.Message.AccountKeys, []PublicKey{ + MustPublicKeyFromBase58("A9QnpgfhCkmiBSjgBuWk76Wo3HxzxvDopUq9x6UUMmjn"), + MustPublicKeyFromBase58("9hFtYBYmBJCVguRYs9pBTWKYAFoKfjYR7zBPpEkVsmD"), + MustPublicKeyFromBase58("6FzXPEhCJoBx7Zw3SN9qhekHemd6E2b8kVguitmVAngW"), + MustPublicKeyFromBase58("SysvarS1otHashes111111111111111111111111111"), + MustPublicKeyFromBase58("SysvarC1ock11111111111111111111111111111111"), + MustPublicKeyFromBase58("11111111111111111111111111111111"), + MustPublicKeyFromBase58("Vote111111111111111111111111111111111111111"), + }) + + assert.Equal(t, trx.Message.Instructions, []CompiledInstruction{ + { + ProgramIDIndex: 5, + AccountCount: 2, + Accounts: []uint8{0, 01}, + DataLength: 2, + Data: []byte{0xaa, 0xbb}, + }, + { + ProgramIDIndex: 6, + AccountCount: 4, + Accounts: []uint8{4, 3, 1, 2}, + DataLength: 2, + Data: []byte{0xcc, 0xdd}, + }, + }) +} diff --git a/types.go b/types.go index d84d6d1..a3d8fa2 100644 --- a/types.go +++ b/types.go @@ -57,7 +57,7 @@ func (t *Transaction) IsWritable(account PublicKey) bool { } h := t.Message.Header return (index < int(h.NumRequiredSignatures-h.NumReadonlySignedAccounts)) || - ((index >= int(h.NumRequiredSignatures)) && (index < len(t.Message.AccountKeys)-int(h.NumReadonlyunsignedAccounts))) + ((index >= int(h.NumRequiredSignatures)) && (index < len(t.Message.AccountKeys)-int(h.NumReadonlyUnsignedAccounts))) } func (t *Transaction) ResolveProgramIDIndex(programIDIndex uint8) (PublicKey, error) { @@ -85,10 +85,14 @@ type Message struct { Instructions []CompiledInstruction `json:"instructions"` } +func (m *Message) signerKeys() []PublicKey { + return m.AccountKeys[0:m.Header.NumRequiredSignatures] +} + type MessageHeader struct { NumRequiredSignatures uint8 `json:"numRequiredSignatures"` NumReadonlySignedAccounts uint8 `json:"numReadonlySignedAccounts"` - NumReadonlyunsignedAccounts uint8 `json:"numReadonlyUnsignedAccounts"` + NumReadonlyUnsignedAccounts uint8 `json:"numReadonlyUnsignedAccounts"` } type CompiledInstruction struct { @@ -108,16 +112,3 @@ func TransactionFromData(in []byte) (*Transaction, error) { } return out, nil } - -type AccountMeta struct { - PublicKey PublicKey - IsSigner bool - IsWritable bool -} - -//func (a *AccountMeta) String() string { -// if a == nil { -// return "" -// } -// return fmt.Sprintf("%s Signer: %t Writable: %t", a.PublicKey.String(), a.IsSigner, a.IsWritable) -//}