diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bbf21066..122f258c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ on: branches: [ "main" ] env: POLYGONRPC: ${{ vars.POLYGONRPC }} + SOLANARPC: ${{ vars.SOLANARPC }} jobs: test: @@ -42,3 +43,6 @@ jobs: - name: TestSigner run: go test -v ./signer/... + + - name: TestComputer + run: go test -v ./computer/... diff --git a/apps/solana/common.go b/apps/solana/common.go new file mode 100644 index 00000000..0ef77034 --- /dev/null +++ b/apps/solana/common.go @@ -0,0 +1,578 @@ +package solana + +import ( + "context" + "crypto/ed25519" + "fmt" + "math/big" + "time" + + "github.com/MixinNetwork/bot-api-go-client/v3" + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/util/base58" + "github.com/MixinNetwork/safe/apps/ethereum" + "github.com/MixinNetwork/safe/common" + "github.com/blocto/solana-go-sdk/types" + "github.com/gagliardetto/solana-go" + tokenAta "github.com/gagliardetto/solana-go/programs/associated-token-account" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/programs/token" +) + +const ( + NonceAccountSize uint64 = 80 + MintSize uint64 = 82 + NormalAccountSize uint64 = 165 + + maxNameLength = 32 + maxSymbolLength = 10 + + SolanaEmptyAddress = "11111111111111111111111111111111" + WrappedSolanaAddress = "So11111111111111111111111111111111111111112" + SolanaChainBase = "64692c23-8971-4cf4-84a7-4dd1271dd887" +) + +type Metadata struct { + Name string `json:"name"` + Symbol string `json:"symbol"` + Description string `json:"description"` + Image string `json:"image"` +} + +type DeployedAsset struct { + AssetId string + ChainId string + Address string + Decimals int64 + CreatedAt time.Time + + Uri string + Asset *bot.AssetNetwork + PrivateKey *solana.PrivateKey +} + +type NonceAccount struct { + Address solana.PublicKey + Hash solana.Hash +} + +type TokenTransfer struct { + SolanaAsset bool + AssetId string + ChainId string + Mint solana.PublicKey + Destination solana.PublicKey + Amount uint64 + Decimals uint8 + Fee bool +} + +type Transfer struct { + // Signature is the signature of the transaction that contains the transfer. + Signature string + + // Index is the index of the transfer in the transaction. + Index int64 + + // TokenAddress is the address of the token that is being transferred. + // If the token is SPL Token, it will be the address of the mint. + // If the token is native SOL, it will be 'SolanaMintAddress'. + TokenAddress string + + // AssetId is the mixin version asset id + AssetId string + + Sender string + Receiver string + Value *big.Int + + MayClosedWsolAta *solana.PublicKey +} + +func FindAssociatedTokenAddress( + wallet solana.PublicKey, + mint solana.PublicKey, + tokenProgramID solana.PublicKey, +) solana.PublicKey { + addr, _, err := solana.FindProgramAddress([][]byte{ + wallet[:], + tokenProgramID[:], + mint[:], + }, + solana.SPLAssociatedTokenAccountProgramID, + ) + if err != nil { + panic(err) + } + return addr +} + +func BuildSignersGetter(keys ...solana.PrivateKey) func(key solana.PublicKey) *solana.PrivateKey { + mapKeys := make(map[solana.PublicKey]*solana.PrivateKey) + for _, k := range keys { + mapKeys[k.PublicKey()] = &k + } + + return func(key solana.PublicKey) *solana.PrivateKey { + return mapKeys[key] + } +} + +func (c *Client) buildInitialTxWithNonceAccount(ctx context.Context, payer solana.PublicKey, nonce NonceAccount) *solana.TransactionBuilder { + b := solana.NewTransactionBuilder() + b.SetRecentBlockHash(nonce.Hash) + b.SetFeePayer(payer) + b.AddInstruction(system.NewAdvanceNonceAccountInstruction( + nonce.Address, + solana.SysVarRecentBlockHashesPubkey, + payer, + ).Build()) + + computerPriceIns := c.getPriorityFeeInstruction(ctx) + b.AddInstruction(computerPriceIns) + return b +} + +func (a *DeployedAsset) PublicKey() solana.PublicKey { + return solana.MustPublicKeyFromBase58(a.Address) +} + +func PrivateKeyFromSeed(seed []byte) solana.PrivateKey { + return solana.PrivateKey(ed25519.NewKeyFromSeed(seed[:])[:]) +} + +func PublicKeyFromEd25519Public(pub string) solana.PublicKey { + return solana.PublicKeyFromBytes(common.DecodeHexOrPanic(pub)) +} + +func VerifyAssetKey(assetKey string) error { + if assetKey == SolanaEmptyAddress { + return nil + } + pub := base58.Decode(assetKey) + if len(pub) != 32 { + return fmt.Errorf("invalid solana assetKey length %s", assetKey) + } + var k crypto.Key + copy(k[:], pub) + if !k.CheckKey() { + return fmt.Errorf("invalid solana assetKey public key %s", assetKey) + } + addr := base58.Encode(pub) + if addr != assetKey { + return fmt.Errorf("invalid solana assetKey %s", assetKey) + } + return nil +} + +func GenerateAssetId(assetKey string) string { + if assetKey == SolanaEmptyAddress { + return common.SafeSolanaChainId + } + err := VerifyAssetKey(assetKey) + if err != nil { + panic(assetKey) + } + + return ethereum.BuildChainAssetId(SolanaChainBase, assetKey) +} + +func ExtractBurnsFromTransaction(ctx context.Context, tx *solana.Transaction) []*token.BurnChecked { + var bs []*token.BurnChecked + msg := tx.Message + for _, cix := range msg.Instructions { + programKey, err := msg.Program(cix.ProgramIDIndex) + if err != nil { + panic(err) + } + switch programKey { + case solana.TokenProgramID, solana.Token2022ProgramID: + default: + continue + } + + accounts, err := cix.ResolveInstructionAccounts(&msg) + if err != nil { + panic(err) + } + burn, ok := DecodeTokenBurn(accounts, cix.Data) + if !ok { + continue + } + bs = append(bs, burn) + } + + return bs +} + +func ExtractCreatedAtasFromTransaction(ctx context.Context, tx *solana.Transaction) []solana.PublicKey { + var as []solana.PublicKey + msg := tx.Message + + for _, cix := range msg.Instructions { + programKey, err := msg.Program(cix.ProgramIDIndex) + if err != nil { + panic(err) + } + if programKey != tokenAta.ProgramID { + continue + } + accounts, err := cix.ResolveInstructionAccounts(&msg) + if err != nil { + panic(err) + } + ix, err := tokenAta.DecodeInstruction(accounts, cix.Data) + if err != nil { + panic(err) + } + if a, ok := ix.Impl.(*tokenAta.Create); ok { + ata := a.GetAccounts()[1] + as = append(as, ata.PublicKey) + } + if a, ok := ix.Impl.(*Create); ok { + ata := a.GetAccounts()[1] + as = append(as, ata.PublicKey) + } + } + + return as +} + +func DecodeSystemTransfer(accounts solana.AccountMetaSlice, data []byte) (*system.Transfer, bool) { + ix, err := system.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + + if transfer, ok := ix.Impl.(*system.Transfer); ok { + return transfer, true + } + + if transferWithSeed, ok := ix.Impl.(*system.TransferWithSeed); ok { + t := system.NewTransferInstructionBuilder() + t.SetFundingAccount(transferWithSeed.GetFundingAccount().PublicKey) + t.SetRecipientAccount(transferWithSeed.GetRecipientAccount().PublicKey) + t.SetLamports(*transferWithSeed.Lamports) + return t, true + } + + return nil, false +} + +func DecodeTokenTransferChecked(accounts solana.AccountMetaSlice, data []byte) (*token.TransferChecked, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + + if transfer, ok := ix.Impl.(*token.TransferChecked); ok { + return transfer, true + } + return nil, false +} + +func decodeTokenTransfer(accounts solana.AccountMetaSlice, data []byte) (*token.Transfer, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + + if transfer, ok := ix.Impl.(*token.Transfer); ok { + return transfer, true + } + + if transferChecked, ok := ix.Impl.(*token.TransferChecked); ok { + t := token.NewTransferInstructionBuilder() + t.SetSourceAccount(transferChecked.GetSourceAccount().PublicKey) + t.SetDestinationAccount(transferChecked.GetDestinationAccount().PublicKey) + t.SetAmount(*transferChecked.Amount) + return t, true + } + + return nil, false +} + +func decodeCloseAccount(accounts solana.AccountMetaSlice, data []byte) (*token.CloseAccount, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + + close, ok := ix.Impl.(*token.CloseAccount) + return close, ok +} + +func decodeTokenInitializeAccount(accounts solana.AccountMetaSlice, data []byte) (*token.InitializeAccount, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + + if init, ok := ix.Impl.(*token.InitializeAccount); ok { + return init, true + } + + if init, ok := ix.Impl.(*token.InitializeAccount2); ok { + i := token.NewInitializeAccountInstructionBuilder() + i.SetAccount(init.GetAccount().PublicKey) + i.SetMintAccount(init.GetMintAccount().PublicKey) + i.SetOwnerAccount(*init.Owner) + return i, true + } + + if init, ok := ix.Impl.(*token.InitializeAccount3); ok { + i := token.NewInitializeAccountInstructionBuilder() + i.SetAccount(init.GetAccount().PublicKey) + i.SetMintAccount(init.GetMintAccount().PublicKey) + i.SetOwnerAccount(*init.Owner) + return i, true + } + + return nil, false +} + +func DecodeTokenBurn(accounts solana.AccountMetaSlice, data []byte) (*token.BurnChecked, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + + if burn, ok := ix.Impl.(*token.BurnChecked); ok { + return burn, true + } + return nil, false +} + +func DecodeCreateAccount(accounts solana.AccountMetaSlice, data []byte) (*system.CreateAccount, bool) { + ix, err := system.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + mint, ok := ix.Impl.(*system.CreateAccount) + if ok { + return mint, true + } + return nil, false +} + +func DecodeMintToken(accounts solana.AccountMetaSlice, data []byte) (*token.InitializeMint2, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + mint, ok := ix.Impl.(*token.InitializeMint2) + if ok { + return mint, true + } + return nil, false +} + +func DecodeTokenMintTo(accounts solana.AccountMetaSlice, data []byte) (*token.MintTo, bool) { + ix, err := token.DecodeInstruction(accounts, data) + if err != nil { + return nil, false + } + mintTo, ok := ix.Impl.(*token.MintTo) + if ok { + return mintTo, true + } + return nil, false +} + +func DecodeNonceAdvance(accounts solana.AccountMetaSlice, data []byte) (*system.AdvanceNonceAccount, error) { + ix, err := system.DecodeInstruction(accounts, data) + if err != nil { + return nil, err + } + advance, ok := ix.Impl.(*system.AdvanceNonceAccount) + if ok { + return advance, nil + } + return nil, fmt.Errorf("invalid nonce advance instruction") +} + +func NonceAccountFromTx(tx *solana.Transaction) (*system.AdvanceNonceAccount, error) { + ins := tx.Message.Instructions[0] + accounts, err := ins.ResolveInstructionAccounts(&tx.Message) + if err != nil { + return nil, err + } + return DecodeNonceAdvance(accounts, ins.Data) +} + +func extractTransfersFromInstruction( + msg *solana.Message, + cix solana.CompiledInstruction, + tokenAccounts map[solana.PublicKey]token.Account, + owners []*solana.PublicKey, + transfers []*Transfer, +) *Transfer { + programKey, err := msg.Program(cix.ProgramIDIndex) + if err != nil { + panic(err) + } + + accounts, err := cix.ResolveInstructionAccounts(msg) + if err != nil { + panic(err) + } + + switch programKey { + case system.ProgramID: + if transfer, ok := DecodeSystemTransfer(accounts, cix.Data); ok { + return &Transfer{ + TokenAddress: SolanaEmptyAddress, + AssetId: SolanaChainBase, + Sender: transfer.GetFundingAccount().PublicKey.String(), + Receiver: transfer.GetRecipientAccount().PublicKey.String(), + Value: new(big.Int).SetUint64(*transfer.Lamports), + } + } + case solana.TokenProgramID, solana.Token2022ProgramID: + // account to receiver token may not be ata + if init, ok := decodeTokenInitializeAccount(accounts, cix.Data); ok { + tokenAccounts[init.GetAccount().PublicKey] = token.Account{ + Owner: init.GetOwnerAccount().PublicKey, + Mint: init.GetMintAccount().PublicKey, + } + } + + if transfer, ok := decodeTokenTransfer(accounts, cix.Data); ok { + from, ok := tokenAccounts[transfer.GetSourceAccount().PublicKey] + if !ok { + panic(fmt.Sprintf("source token account not found: %s", transfer.GetSourceAccount().PublicKey.String())) + } + + to, ok := tokenAccounts[transfer.GetDestinationAccount().PublicKey] + if !ok { + if from.Mint.String() == WrappedSolanaAddress { + for _, owner := range owners { + ata := FindAssociatedTokenAddress(*owner, from.Mint, programKey) + if ata.Equals(transfer.GetDestinationAccount().PublicKey) { + return &Transfer{ + TokenAddress: from.Mint.String(), + AssetId: ethereum.BuildChainAssetId(SolanaChainBase, from.Mint.String()), + Sender: from.Owner.String(), + Receiver: owner.String(), + Value: new(big.Int).SetUint64(*transfer.Amount), + MayClosedWsolAta: &ata, + } + } + } + } + panic(fmt.Sprintf("destination token account not found: %s", transfer.GetDestinationAccount().PublicKey.String())) + } + + return &Transfer{ + TokenAddress: from.Mint.String(), + AssetId: ethereum.BuildChainAssetId(SolanaChainBase, from.Mint.String()), + Sender: from.Owner.String(), + Receiver: to.Owner.String(), + Value: new(big.Int).SetUint64(*transfer.Amount), + } + } + + // check WSOL transfer and WSOL token account closed + if close, ok := decodeCloseAccount(accounts, cix.Data); ok { + closed := close.GetAccount().PublicKey + if owner, ok := tokenAccounts[closed]; ok { + for index, transfer := range transfers { + if !solana.MustPublicKeyFromBase58(transfer.Receiver).Equals(owner.Owner) || transfer.TokenAddress != WrappedSolanaAddress { + continue + } + transfers[index].TokenAddress = SolanaEmptyAddress + transfers[index].AssetId = SolanaChainBase + transfers[index].MayClosedWsolAta = nil + } + return nil + } + + for index, transfer := range transfers { + if transfer.MayClosedWsolAta == nil || !transfer.MayClosedWsolAta.Equals(closed) { + continue + } + transfers[index].TokenAddress = SolanaEmptyAddress + transfers[index].AssetId = SolanaChainBase + transfers[index].MayClosedWsolAta = nil + } + } + } + + return nil +} + +func ExtractInitialTransfersFromInstruction( + msg *solana.Message, + cix solana.CompiledInstruction, +) *Transfer { + programKey, err := msg.Program(cix.ProgramIDIndex) + if err != nil { + panic(err) + } + + accounts, err := cix.ResolveInstructionAccounts(msg) + if err != nil { + panic(err) + } + + switch programKey { + case system.ProgramID: + if transfer, ok := DecodeSystemTransfer(accounts, cix.Data); ok { + return &Transfer{ + TokenAddress: SolanaEmptyAddress, + AssetId: SolanaChainBase, + Sender: transfer.GetFundingAccount().PublicKey.String(), + Receiver: transfer.GetRecipientAccount().PublicKey.String(), + Value: new(big.Int).SetUint64(*transfer.Lamports), + } + } + case solana.TokenProgramID, solana.Token2022ProgramID: + if transfer, ok := DecodeTokenTransferChecked(accounts, cix.Data); ok { + from := transfer.GetOwnerAccount().PublicKey.String() + mint := transfer.GetMintAccount().PublicKey.String() + + return &Transfer{ + TokenAddress: mint, + AssetId: ethereum.BuildChainAssetId(SolanaChainBase, mint), + Sender: from, + Receiver: from, + Value: new(big.Int).SetUint64(*transfer.Amount), + } + } + if mint, ok := DecodeTokenMintTo(accounts, cix.Data); ok { + addr := mint.GetMintAccount().PublicKey.String() + return &Transfer{ + TokenAddress: addr, + AssetId: ethereum.BuildChainAssetId(SolanaChainBase, addr), + Receiver: mint.GetDestinationAccount().PublicKey.String(), + Value: new(big.Int).SetUint64(*mint.Amount), + } + } + } + + return nil +} + +type CustomInstruction struct { + Instruction types.Instruction +} + +func (cs CustomInstruction) ProgramID() solana.PublicKey { + return solana.MustPublicKeyFromBase58(cs.Instruction.ProgramID.String()) +} + +func (cs CustomInstruction) Accounts() []*solana.AccountMeta { + var as []*solana.AccountMeta + for _, a := range cs.Instruction.Accounts { + as = append(as, &solana.AccountMeta{ + PublicKey: solana.MustPublicKeyFromBase58(a.PubKey.String()), + IsWritable: a.IsWritable, + IsSigner: a.IsSigner, + }) + } + return as +} + +func (cs CustomInstruction) Data() ([]byte, error) { + return cs.Instruction.Data, nil +} diff --git a/apps/solana/rpc.go b/apps/solana/rpc.go new file mode 100644 index 00000000..f7172111 --- /dev/null +++ b/apps/solana/rpc.go @@ -0,0 +1,275 @@ +package solana + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/safe/mtg" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/programs/token" + "github.com/gagliardetto/solana-go/rpc" +) + +func NewClient(rpcEndpoint string) *Client { + return &Client{ + rpcEndpoint: rpcEndpoint, + rpcClient: rpc.New(rpcEndpoint), + } +} + +type Client struct { + rpcClient *rpc.Client + rpcEndpoint string +} + +type AssetMetadata struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + Description string `json:"description"` +} + +type Asset struct { + Address string `json:"address"` + Id string `json:"id"` + Decimals uint32 `json:"decimals"` + MintAuthority string `json:"mint_authority"` + FreezeAuthority string `json:"freeze_authority"` +} + +func (c *Client) RPCGetConfirmedHeight(ctx context.Context) (uint64, error) { + for { + block, err := c.rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + if err != nil { + return 0, fmt.Errorf("solana.GetLatestBlockhash() => %v", err) + } + return block.Context.Slot, nil + } +} + +func (c *Client) RPCGetBlockByHeight(ctx context.Context, height uint64) (*rpc.GetBlockResult, error) { + for { + block, err := c.rpcClient.GetBlockWithOpts(ctx, height, &rpc.GetBlockOpts{ + Encoding: solana.EncodingBase64, + Commitment: rpc.CommitmentProcessed, + MaxSupportedTransactionVersion: &rpc.MaxSupportedTransactionVersion1, + TransactionDetails: rpc.TransactionDetailsFull, + }) + if mtg.CheckRetryableError(err) || errors.Is(err, rpc.ErrNotFound) { + time.Sleep(1 * time.Second) + continue + } + if err != nil { + return nil, err + } + return block, nil + } +} + +type MintData struct { + Parsed struct { + Info struct { + MintAuthority *solana.PublicKey `bin:"optional"` + Supply string + Decimals uint8 + IsInitialized bool + FreezeAuthority *solana.PublicKey `bin:"optional"` + } `json:"info"` + } `json:"parsed"` +} + +func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error) { + for { + account, err := c.rpcClient.GetAccountInfoWithOpts(ctx, solana.MPK(address), &rpc.GetAccountInfoOpts{ + Encoding: "jsonParsed", + Commitment: rpc.CommitmentProcessed, + }) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + if err != nil { + return nil, err + } + data, err := account.Value.Data.MarshalJSON() + if err != nil { + panic(err) + } + var mint MintData + err = json.Unmarshal(data, &mint) + if err != nil { + panic(err) + } + + freezeAuthority := "" + if mint.Parsed.Info.FreezeAuthority != nil { + freezeAuthority = mint.Parsed.Info.FreezeAuthority.String() + } + asset := &Asset{ + Address: address, + Id: GenerateAssetId(address), + Decimals: uint32(mint.Parsed.Info.Decimals), + MintAuthority: mint.Parsed.Info.MintAuthority.String(), + FreezeAuthority: freezeAuthority, + } + return asset, nil + } +} + +func (c *Client) RPCGetBalance(ctx context.Context, account solana.PublicKey) (uint64, error) { + for { + result, err := c.rpcClient.GetBalance(ctx, account, rpc.CommitmentProcessed) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + if err != nil { + return 0, fmt.Errorf("solana.GetAccountInfo(%s) => %v", account, err) + } + return result.Value, nil + } +} + +func (c *Client) RPCGetAccount(ctx context.Context, account solana.PublicKey) (*rpc.GetAccountInfoResult, error) { + for { + result, err := c.rpcClient.GetAccountInfoWithOpts(ctx, account, &rpc.GetAccountInfoOpts{ + Commitment: rpc.CommitmentProcessed, + }) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + if err != nil && !errors.Is(err, rpc.ErrNotFound) { + return nil, fmt.Errorf("solana.GetAccountInfo(%s) => %v", account, err) + } + return result, nil + } +} + +func (c *Client) RPCGetMultipleAccounts(ctx context.Context, accounts solana.PublicKeySlice) (*rpc.GetMultipleAccountsResult, error) { + for { + as, err := c.rpcClient.GetMultipleAccountsWithOpts(ctx, accounts, &rpc.GetMultipleAccountsOpts{ + Commitment: rpc.CommitmentProcessed, + }) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + return as, err + } +} + +func (c *Client) RPCGetTransaction(ctx context.Context, signature string) (*rpc.GetTransactionResult, error) { + for { + r, err := c.rpcClient.GetTransaction(ctx, + solana.MustSignatureFromBase58(signature), + &rpc.GetTransactionOpts{ + Encoding: solana.EncodingBase58, + MaxSupportedTransactionVersion: &rpc.MaxSupportedTransactionVersion1, + Commitment: rpc.CommitmentConfirmed, // getTransaction requires this min level + }, + ) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + if err != nil || r.Meta == nil { + if strings.Contains(err.Error(), "not found") { + return nil, nil + } + return nil, fmt.Errorf("solana.GetTransaction(%s) => %v", signature, err) + } + + return r, nil + } +} + +func (c *Client) RPCGetMinimumBalanceForRentExemption(ctx context.Context, dataSize uint64) (uint64, error) { + for { + r, err := c.rpcClient.GetMinimumBalanceForRentExemption(ctx, dataSize, rpc.CommitmentProcessed) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + return r, err + } +} + +func (c *Client) RPCGetTokenAccountsByOwner(ctx context.Context, owner solana.PublicKey) ([]*token.Account, error) { + for { + r, err := c.rpcClient.GetTokenAccountsByOwner(ctx, owner, &rpc.GetTokenAccountsConfig{ + ProgramId: &token.ProgramID, + }, nil) + if mtg.CheckRetryableError(err) { + time.Sleep(1 * time.Second) + continue + } + if err != nil { + return nil, err + } + + as := make([]*token.Account, len(r.Value)) + for i, account := range r.Value { + var a token.Account + err := bin.NewBinDecoder(account.Account.Data.GetBinary()).Decode(&a) + if err != nil { + return nil, fmt.Errorf("solana.NewBinDecoder() => %v", err) + } + as[i] = &a + } + return as, nil + } +} + +func (c *Client) GetNonceAccountHash(ctx context.Context, nonce solana.PublicKey) (*solana.Hash, error) { + account, err := c.RPCGetAccount(ctx, nonce) + if err != nil { + return nil, fmt.Errorf("solana.GetAccountInfo() => %v", err) + } + if account == nil { + return nil, nil + } + var nonceAccountData system.NonceAccount + err = bin.NewBinDecoder(account.Value.Data.GetBinary()).Decode(&nonceAccountData) + if err != nil { + return nil, fmt.Errorf("solana.NewBinDecoder() => %v", err) + } + hash := solana.Hash(nonceAccountData.Nonce) + return &hash, nil +} + +func (c *Client) GetMint(ctx context.Context, mint solana.PublicKey) (*token.Mint, error) { + account, err := c.RPCGetAccount(ctx, mint) + if err != nil { + return nil, fmt.Errorf("solana.GetMint() => %v", err) + } + if account == nil { + return nil, nil + } + var token token.Mint + err = bin.NewBinDecoder(account.Value.Data.GetBinary()).Decode(&token) + if err != nil { + return nil, fmt.Errorf("solana.NewBinDecoder() => %v", err) + } + return &token, nil +} + +func (c *Client) SendTransaction(ctx context.Context, tx *solana.Transaction) (string, error) { + sig, err := c.rpcClient.SendTransactionWithOpts(ctx, tx, rpc.TransactionOpts{ + SkipPreflight: false, + PreflightCommitment: rpc.CommitmentProcessed, + }) + if err != nil { + return "", err + } + return sig.String(), nil +} diff --git a/apps/solana/token2022_ata.go b/apps/solana/token2022_ata.go new file mode 100644 index 00000000..058b4760 --- /dev/null +++ b/apps/solana/token2022_ata.go @@ -0,0 +1,186 @@ +package solana + +import ( + "errors" + + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + tokenAta "github.com/gagliardetto/solana-go/programs/associated-token-account" + format "github.com/gagliardetto/solana-go/text/format" + treeout "github.com/gagliardetto/treeout" +) + +type Create struct { + Payer solana.PublicKey `bin:"-" borsh_skip:"true"` + Wallet solana.PublicKey `bin:"-" borsh_skip:"true"` + Mint solana.PublicKey `bin:"-" borsh_skip:"true"` + + // [0] = [WRITE, SIGNER] Payer + // ··········· Funding account + // + // [1] = [WRITE] AssociatedTokenAccount + // ··········· Associated token account address to be created + // + // [2] = [] Wallet + // ··········· Wallet address for the new associated token account + // + // [3] = [] TokenMint + // ··········· The token mint for the new associated token account + // + // [4] = [] SystemProgram + // ··········· System program ID + // + // [5] = [] TokenProgram + // ··········· SPL token program ID + // + // [6] = [] SysVarRent + // ··········· SysVarRentPubkey + solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +// NewCreateInstructionBuilder creates a new `Create` instruction builder. +func NewCreateInstructionBuilder() *Create { + nd := &Create{} + return nd +} + +func (inst *Create) SetPayer(payer solana.PublicKey) *Create { + inst.Payer = payer + return inst +} + +func (inst *Create) SetWallet(wallet solana.PublicKey) *Create { + inst.Wallet = wallet + return inst +} + +func (inst *Create) SetMint(mint solana.PublicKey) *Create { + inst.Mint = mint + return inst +} + +func (inst Create) Build() *tokenAta.Instruction { + // Find the associatedTokenAddress; + associatedTokenAddress := FindAssociatedTokenAddress( + inst.Wallet, + inst.Mint, + solana.Token2022ProgramID, + ) + + keys := []*solana.AccountMeta{ + { + PublicKey: inst.Payer, + IsSigner: true, + IsWritable: true, + }, + { + PublicKey: associatedTokenAddress, + IsSigner: false, + IsWritable: true, + }, + { + PublicKey: inst.Wallet, + IsSigner: false, + IsWritable: false, + }, + { + PublicKey: inst.Mint, + IsSigner: false, + IsWritable: false, + }, + { + PublicKey: solana.SystemProgramID, + IsSigner: false, + IsWritable: false, + }, + { + PublicKey: solana.Token2022ProgramID, // origin: solana.TokenProgramID + IsSigner: false, + IsWritable: false, + }, + { + PublicKey: solana.SysVarRentPubkey, + IsSigner: false, + IsWritable: false, + }, + } + + inst.AccountMetaSlice = keys + + return &tokenAta.Instruction{BaseVariant: bin.BaseVariant{ + Impl: inst, + TypeID: bin.NoTypeIDDefaultID, + }} +} + +// ValidateAndBuild validates the instruction accounts. +// If there is a validation error, return the error. +// Otherwise, build and return the instruction. +func (inst Create) ValidateAndBuild() (*tokenAta.Instruction, error) { + if err := inst.Validate(); err != nil { + return nil, err + } + return inst.Build(), nil +} + +func (inst *Create) Validate() error { + if inst.Payer.IsZero() { + return errors.New("payer not set") + } + if inst.Wallet.IsZero() { + return errors.New("wallet not set") + } + if inst.Mint.IsZero() { + return errors.New("mint not set") + } + _ = FindAssociatedTokenAddress( + inst.Wallet, + inst.Mint, + solana.Token2022ProgramID, + ) + return nil +} + +func (inst *Create) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program(tokenAta.ProgramName, tokenAta.ProgramID)). + // + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child(format.Instruction("Create")). + // + ParentFunc(func(instructionBranch treeout.Branches) { + + // Parameters of the instruction: + instructionBranch.Child("Params[len=0]").ParentFunc(func(paramsBranch treeout.Branches) {}) + + // Accounts of the instruction: + instructionBranch.Child("Accounts[len=7").ParentFunc(func(accountsBranch treeout.Branches) { + accountsBranch.Child(format.Meta(" payer", inst.AccountMetaSlice.Get(0))) + accountsBranch.Child(format.Meta("associatedTokenAddress", inst.AccountMetaSlice.Get(1))) + accountsBranch.Child(format.Meta(" wallet", inst.AccountMetaSlice.Get(2))) + accountsBranch.Child(format.Meta(" tokenMint", inst.AccountMetaSlice.Get(3))) + accountsBranch.Child(format.Meta(" systemProgram", inst.AccountMetaSlice.Get(4))) + accountsBranch.Child(format.Meta(" tokenProgram", inst.AccountMetaSlice.Get(5))) + accountsBranch.Child(format.Meta(" sysVarRent", inst.AccountMetaSlice.Get(6))) + }) + }) + }) +} + +func (inst Create) MarshalWithEncoder(encoder *bin.Encoder) error { + return encoder.WriteBytes([]byte{}, false) +} + +func (inst *Create) UnmarshalWithDecoder(decoder *bin.Decoder) error { + return nil +} + +func NewAta2022CreateInstruction( + payer solana.PublicKey, + walletAddress solana.PublicKey, + splTokenMintAddress solana.PublicKey, +) *Create { + return NewCreateInstructionBuilder(). + SetPayer(payer). + SetWallet(walletAddress). + SetMint(splTokenMintAddress) +} diff --git a/apps/solana/token2022_transferChecked.go b/apps/solana/token2022_transferChecked.go new file mode 100644 index 00000000..f3c24092 --- /dev/null +++ b/apps/solana/token2022_transferChecked.go @@ -0,0 +1,356 @@ +package solana + +import ( + "bytes" + "errors" + "fmt" + + ag_spew "github.com/davecgh/go-spew/spew" + ag_binary "github.com/gagliardetto/binary" + ag_solanago "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/token" + ag_text "github.com/gagliardetto/solana-go/text" + ag_format "github.com/gagliardetto/solana-go/text/format" + ag_treeout "github.com/gagliardetto/treeout" +) + +const MAX_SIGNERS = 11 +const ProgramName = "Token 2022" + +type Instruction struct { + ag_binary.BaseVariant +} + +var InstructionImplDef = ag_binary.NewVariantDefinition( + ag_binary.Uint8TypeIDEncoding, + []ag_binary.VariantType{ + { + Name: "TransferChecked", Type: (*TransferChecked)(nil), + }, + }, +) + +func (inst *Instruction) ProgramID() ag_solanago.PublicKey { + return ag_solanago.Token2022ProgramID +} + +func (inst *Instruction) Accounts() (out []*ag_solanago.AccountMeta) { + return inst.Impl.(ag_solanago.AccountsGettable).GetAccounts() +} + +func (inst *Instruction) Data() ([]byte, error) { + buf := new(bytes.Buffer) + if err := ag_binary.NewBinEncoder(buf).Encode(inst); err != nil { + return nil, fmt.Errorf("unable to encode instruction: %w", err) + } + return buf.Bytes(), nil +} + +func (inst *Instruction) EncodeToTree(parent ag_treeout.Branches) { + if enToTree, ok := inst.Impl.(ag_text.EncodableToTree); ok { + enToTree.EncodeToTree(parent) + } else { + parent.Child(ag_spew.Sdump(inst)) + } +} + +func (inst *Instruction) TextEncode(encoder *ag_text.Encoder, option *ag_text.Option) error { + return encoder.Encode(inst.Impl, option) +} + +func (inst *Instruction) UnmarshalWithDecoder(decoder *ag_binary.Decoder) error { + return inst.BaseVariant.UnmarshalBinaryVariant(decoder, InstructionImplDef) +} + +func (inst Instruction) MarshalWithEncoder(encoder *ag_binary.Encoder) error { + err := encoder.WriteUint8(inst.TypeID.Uint8()) + if err != nil { + return fmt.Errorf("unable to write variant type: %w", err) + } + return encoder.Encode(inst.Impl) +} + +func registryDecodeInstruction(accounts []*ag_solanago.AccountMeta, data []byte) (any, error) { + inst, err := DecodeInstruction(accounts, data) + if err != nil { + return nil, err + } + return inst, nil +} + +func DecodeInstruction(accounts []*ag_solanago.AccountMeta, data []byte) (*Instruction, error) { + inst := new(Instruction) + if err := ag_binary.NewBinDecoder(data).Decode(inst); err != nil { + return nil, fmt.Errorf("unable to decode instruction: %w", err) + } + if v, ok := inst.Impl.(ag_solanago.AccountsSettable); ok { + err := v.SetAccounts(accounts) + if err != nil { + return nil, fmt.Errorf("unable to set accounts for instruction: %w", err) + } + } + return inst, nil +} + +// Transfers tokens from one account to another either directly or via a +// delegate. If this account is associated with the native mint then equal +// amounts of SOL and Tokens will be transferred to the destination +// account. +// +// This instruction differs from Transfer in that the token mint and +// decimals value is checked by the caller. This may be useful when +// creating transactions offline or within a hardware wallet. +type TransferChecked struct { + // The amount of tokens to transfer. + Amount *uint64 + + // Expected number of base 10 digits to the right of the decimal place. + Decimals *uint8 + + // [0] = [WRITE] source + // ··········· The source account. + // + // [1] = [] mint + // ··········· The token mint. + // + // [2] = [WRITE] destination + // ··········· The destination account. + // + // [3] = [] owner + // ··········· The source account's owner/delegate. + // + // [4...] = [SIGNER] signers + // ··········· M signer accounts. + Accounts ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` + Signers ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +func (obj *TransferChecked) SetAccounts(accounts []*ag_solanago.AccountMeta) error { + obj.Accounts, obj.Signers = ag_solanago.AccountMetaSlice(accounts).SplitFrom(4) + return nil +} + +func (slice TransferChecked) GetAccounts() (accounts []*ag_solanago.AccountMeta) { + accounts = append(accounts, slice.Accounts...) + accounts = append(accounts, slice.Signers...) + return +} + +// NewTransferCheckedInstructionBuilder creates a new `TransferChecked` instruction builder. +func NewTransferCheckedInstructionBuilder() *TransferChecked { + nd := &TransferChecked{ + Accounts: make(ag_solanago.AccountMetaSlice, 4), + Signers: make(ag_solanago.AccountMetaSlice, 0), + } + return nd +} + +// SetAmount sets the "amount" parameter. +// The amount of tokens to transfer. +func (inst *TransferChecked) SetAmount(amount uint64) *TransferChecked { + inst.Amount = &amount + return inst +} + +// SetDecimals sets the "decimals" parameter. +// Expected number of base 10 digits to the right of the decimal place. +func (inst *TransferChecked) SetDecimals(decimals uint8) *TransferChecked { + inst.Decimals = &decimals + return inst +} + +// SetSourceAccount sets the "source" account. +// The source account. +func (inst *TransferChecked) SetSourceAccount(source ag_solanago.PublicKey) *TransferChecked { + inst.Accounts[0] = ag_solanago.Meta(source).WRITE() + return inst +} + +// GetSourceAccount gets the "source" account. +// The source account. +func (inst *TransferChecked) GetSourceAccount() *ag_solanago.AccountMeta { + return inst.Accounts[0] +} + +// SetMintAccount sets the "mint" account. +// The token mint. +func (inst *TransferChecked) SetMintAccount(mint ag_solanago.PublicKey) *TransferChecked { + inst.Accounts[1] = ag_solanago.Meta(mint) + return inst +} + +// GetMintAccount gets the "mint" account. +// The token mint. +func (inst *TransferChecked) GetMintAccount() *ag_solanago.AccountMeta { + return inst.Accounts[1] +} + +// SetDestinationAccount sets the "destination" account. +// The destination account. +func (inst *TransferChecked) SetDestinationAccount(destination ag_solanago.PublicKey) *TransferChecked { + inst.Accounts[2] = ag_solanago.Meta(destination).WRITE() + return inst +} + +// GetDestinationAccount gets the "destination" account. +// The destination account. +func (inst *TransferChecked) GetDestinationAccount() *ag_solanago.AccountMeta { + return inst.Accounts[2] +} + +// SetOwnerAccount sets the "owner" account. +// The source account's owner/delegate. +func (inst *TransferChecked) SetOwnerAccount(owner ag_solanago.PublicKey, multisigSigners ...ag_solanago.PublicKey) *TransferChecked { + inst.Accounts[3] = ag_solanago.Meta(owner) + if len(multisigSigners) == 0 { + inst.Accounts[3].SIGNER() + } + for _, signer := range multisigSigners { + inst.Signers = append(inst.Signers, ag_solanago.Meta(signer).SIGNER()) + } + return inst +} + +// GetOwnerAccount gets the "owner" account. +// The source account's owner/delegate. +func (inst *TransferChecked) GetOwnerAccount() *ag_solanago.AccountMeta { + return inst.Accounts[3] +} + +func (inst TransferChecked) Build() *Instruction { + return &Instruction{BaseVariant: ag_binary.BaseVariant{ + Impl: inst, + TypeID: ag_binary.TypeIDFromUint8(token.Instruction_TransferChecked), + }} +} + +// ValidateAndBuild validates the instruction parameters and accounts; +// if there is a validation error, it returns the error. +// Otherwise, it builds and returns the instruction. +func (inst TransferChecked) ValidateAndBuild() (*Instruction, error) { + if err := inst.Validate(); err != nil { + return nil, err + } + return inst.Build(), nil +} + +func (inst *TransferChecked) Validate() error { + // Check whether all (required) parameters are set: + { + if inst.Amount == nil { + return errors.New("amount parameter is not set") + } + if inst.Decimals == nil { + return errors.New("decimals parameter is not set") + } + } + + // Check whether all (required) accounts are set: + { + if inst.Accounts[0] == nil { + return errors.New("accounts.Source is not set") + } + if inst.Accounts[1] == nil { + return errors.New("accounts.Mint is not set") + } + if inst.Accounts[2] == nil { + return errors.New("accounts.Destination is not set") + } + if inst.Accounts[3] == nil { + return errors.New("accounts.Owner is not set") + } + if !inst.Accounts[3].IsSigner && len(inst.Signers) == 0 { + return fmt.Errorf("accounts.Signers is not set") + } + if len(inst.Signers) > MAX_SIGNERS { + return fmt.Errorf("too many signers; got %v, but max is 11", len(inst.Signers)) + } + } + return nil +} + +func (inst *TransferChecked) EncodeToTree(parent ag_treeout.Branches) { + parent.Child(ag_format.Program(ProgramName, ag_solanago.Token2022ProgramID)). + // + ParentFunc(func(programBranch ag_treeout.Branches) { + programBranch.Child(ag_format.Instruction("TransferChecked")). + // + ParentFunc(func(instructionBranch ag_treeout.Branches) { + + // Parameters of the instruction: + instructionBranch.Child("Params").ParentFunc(func(paramsBranch ag_treeout.Branches) { + paramsBranch.Child(ag_format.Param(" Amount", *inst.Amount)) + paramsBranch.Child(ag_format.Param("Decimals", *inst.Decimals)) + }) + + // Accounts of the instruction: + instructionBranch.Child("Accounts").ParentFunc(func(accountsBranch ag_treeout.Branches) { + accountsBranch.Child(ag_format.Meta(" source", inst.Accounts[0])) + accountsBranch.Child(ag_format.Meta(" mint", inst.Accounts[1])) + accountsBranch.Child(ag_format.Meta("destination", inst.Accounts[2])) + accountsBranch.Child(ag_format.Meta(" owner", inst.Accounts[3])) + + signersBranch := accountsBranch.Child(fmt.Sprintf("signers[len=%v]", len(inst.Signers))) + for i, v := range inst.Signers { + if len(inst.Signers) > 9 && i < 10 { + signersBranch.Child(ag_format.Meta(fmt.Sprintf(" [%v]", i), v)) + } else { + signersBranch.Child(ag_format.Meta(fmt.Sprintf("[%v]", i), v)) + } + } + }) + }) + }) +} + +func (obj TransferChecked) MarshalWithEncoder(encoder *ag_binary.Encoder) (err error) { + // Serialize `Amount` param: + err = encoder.Encode(obj.Amount) + if err != nil { + return err + } + // Serialize `Decimals` param: + err = encoder.Encode(obj.Decimals) + if err != nil { + return err + } + return nil +} +func (obj *TransferChecked) UnmarshalWithDecoder(decoder *ag_binary.Decoder) (err error) { + // Deserialize `Amount`: + err = decoder.Decode(&obj.Amount) + if err != nil { + return err + } + // Deserialize `Decimals`: + err = decoder.Decode(&obj.Decimals) + if err != nil { + return err + } + return nil +} + +// NewTransferCheckedInstruction declares a new TransferChecked instruction with the provided parameters and accounts. +func NewToken2022TransferCheckedInstruction( + // Parameters: + amount uint64, + decimals uint8, + // Accounts: + source ag_solanago.PublicKey, + mint ag_solanago.PublicKey, + destination ag_solanago.PublicKey, + owner ag_solanago.PublicKey, + multisigSigners []ag_solanago.PublicKey, +) *TransferChecked { + return NewTransferCheckedInstructionBuilder(). + SetAmount(amount). + SetDecimals(decimals). + SetSourceAccount(source). + SetMintAccount(mint). + SetDestinationAccount(destination). + SetOwnerAccount(owner, multisigSigners...) +} + +func init() { + ag_solanago.RegisterInstructionDecoder(ag_solanago.Token2022ProgramID, registryDecodeInstruction) +} diff --git a/apps/solana/transaction.go b/apps/solana/transaction.go new file mode 100644 index 00000000..4634244e --- /dev/null +++ b/apps/solana/transaction.go @@ -0,0 +1,472 @@ +package solana + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/MixinNetwork/safe/common" + sc "github.com/blocto/solana-go-sdk/common" + meta "github.com/blocto/solana-go-sdk/program/metaplex/token_metadata" + "github.com/gagliardetto/solana-go" + tokenAta "github.com/gagliardetto/solana-go/programs/associated-token-account" + computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/programs/token" + "github.com/gagliardetto/solana-go/rpc" + "github.com/shopspring/decimal" +) + +func (c *Client) CreateNonceAccount(ctx context.Context, key, nonce string, rent uint64) (*solana.Transaction, error) { + payer, err := solana.PrivateKeyFromBase58(key) + if err != nil { + panic(err) + } + nonceKey, err := solana.PrivateKeyFromBase58(nonce) + if err != nil { + panic(err) + } + + computerPriceIns := c.getPriorityFeeInstruction(ctx) + block, err := c.rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentProcessed) + if err != nil { + return nil, fmt.Errorf("solana.GetLatestBlockhash() => %v", err) + } + blockhash := block.Value.Blockhash + + tx, err := solana.NewTransaction( + []solana.Instruction{ + system.NewCreateAccountInstruction( + rent, + NonceAccountSize, + system.ProgramID, + payer.PublicKey(), + nonceKey.PublicKey(), + ).Build(), + system.NewInitializeNonceAccountInstruction( + payer.PublicKey(), + nonceKey.PublicKey(), + solana.SysVarRecentBlockHashesPubkey, + solana.SysVarRentPubkey, + ).Build(), + computerPriceIns, + }, + blockhash, + solana.TransactionPayer(payer.PublicKey()), + ) + if err != nil { + panic(err) + } + _, err = tx.Sign(BuildSignersGetter(nonceKey, payer)) + if err != nil { + panic(err) + } + return tx, nil +} + +func (c *Client) InitializeAccount(ctx context.Context, key, user string) (*solana.Transaction, error) { + payer, err := solana.PrivateKeyFromBase58(key) + if err != nil { + panic(err) + } + dst, err := solana.PublicKeyFromBase58(user) + if err != nil { + panic(err) + } + + computerPriceIns := c.getPriorityFeeInstruction(ctx) + + rentExemptBalance, err := c.RPCGetMinimumBalanceForRentExemption(ctx, NormalAccountSize) + if err != nil { + return nil, fmt.Errorf("soalan.GetMinimumBalanceForRentExemption(%d) => %v", NormalAccountSize, err) + } + block, err := c.rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentProcessed) + if err != nil { + return nil, fmt.Errorf("solana.GetLatestBlockhash() => %v", err) + } + blockhash := block.Value.Blockhash + + tx, err := solana.NewTransaction( + []solana.Instruction{ + system.NewTransferInstruction( + rentExemptBalance, + payer.PublicKey(), + dst, + ).Build(), + computerPriceIns, + }, + blockhash, + solana.TransactionPayer(payer.PublicKey()), + ) + if err != nil { + panic(err) + } + _, err = tx.Sign(BuildSignersGetter(payer)) + if err != nil { + panic(err) + } + return tx, nil +} + +func (c *Client) CreateMints(ctx context.Context, payer, mtg solana.PublicKey, assets []*DeployedAsset, rent uint64) (*solana.Transaction, error) { + builder := solana.NewTransactionBuilder() + builder.SetFeePayer(payer) + + for _, asset := range assets { + if asset.ChainId == SolanaChainBase { + return nil, fmt.Errorf("CreateMints(%s) => invalid asset chain", asset.AssetId) + } + mint := solana.MustPublicKeyFromBase58(asset.Address) + + builder.AddInstruction( + system.NewCreateAccountInstruction( + rent, + MintSize, + token.ProgramID, + payer, + mint, + ).Build(), + ) + builder.AddInstruction( + token.NewInitializeMint2InstructionBuilder(). + SetDecimals(uint8(asset.Asset.Precision)). + SetMintAuthority(payer). + SetMintAccount(solana.MustPublicKeyFromBase58(asset.Address)).Build(), + ) + + pda, _, err := solana.FindTokenMetadataAddress(mint) + if err != nil { + return nil, err + } + name := asset.Asset.Name + if len(name) > maxNameLength { + name = name[:maxNameLength] + } + symbol := asset.Asset.Symbol + if len(symbol) > maxSymbolLength { + name = name[:maxSymbolLength] + } + builder.AddInstruction( + CustomInstruction{ + Instruction: meta.CreateMetadataAccountV3(meta.CreateMetadataAccountV3Param{ + Metadata: sc.PublicKeyFromString(pda.String()), + Mint: sc.PublicKeyFromString(mint.String()), + MintAuthority: sc.PublicKeyFromString(payer.String()), + Payer: sc.PublicKeyFromString(payer.String()), + UpdateAuthority: sc.PublicKeyFromString(mtg.String()), + UpdateAuthorityIsSigner: false, + IsMutable: false, + Data: meta.DataV2{ + Name: name, + Symbol: symbol, + Uri: asset.Uri, + SellerFeeBasisPoints: 0, + }, + }), + }, + ) + + builder.AddInstruction( + token.NewSetAuthorityInstruction(token.AuthorityMintTokens, mtg, mint, payer, nil).Build(), + ) + } + + computerPriceIns := c.getPriorityFeeInstruction(ctx) + builder.AddInstruction(computerPriceIns) + + block, err := c.rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentProcessed) + if err != nil { + return nil, fmt.Errorf("solana.GetLatestBlockhash() => %v", err) + } + builder.SetRecentBlockHash(block.Value.Blockhash) + + tx, err := builder.Build() + if err != nil { + panic(err) + } + for _, asset := range assets { + if asset.PrivateKey == nil { + return nil, fmt.Errorf("CreateMints(%s) => asset private key is required", asset.AssetId) + } + _, err = tx.PartialSign(BuildSignersGetter(*asset.PrivateKey)) + if err != nil { + if common.CheckTestEnvironment(ctx) { + tx.Signatures[1] = solana.MustSignatureFromBase58("449h9tg5hCHigegVuH6Waoh8ACDYc5hrhZh2t9td2ToFgtBHrkzH7Z2vSE2nnmNdksUkj71k7eaQhdHrRgj19b5W") + continue + } + panic(err) + } + } + return tx, nil +} + +func (c *Client) TransferOrMintTokens(ctx context.Context, payer, mtg solana.PublicKey, nonce NonceAccount, transfers []*TokenTransfer) (*solana.Transaction, error) { + builder := c.buildInitialTxWithNonceAccount(ctx, payer, nonce) + + for _, transfer := range transfers { + if transfer.SolanaAsset { + b, err := c.addTransferSolanaAssetInstruction(ctx, builder, transfer, payer, mtg) + if err != nil { + return nil, err + } + builder = b + continue + } + + mint := transfer.Mint + ataAddress := FindAssociatedTokenAddress(transfer.Destination, mint, solana.TokenProgramID) + ata, err := c.RPCGetAccount(ctx, ataAddress) + if err != nil { + return nil, err + } + if ata == nil || common.CheckTestEnvironment(ctx) { + builder.AddInstruction( + tokenAta.NewCreateInstruction( + payer, + transfer.Destination, + mint, + ).Build(), + ) + } + + builder.AddInstruction( + token.NewMintToInstruction( + transfer.Amount, + mint, + ataAddress, + mtg, + nil, + ).Build(), + ) + } + + tx, err := builder.Build() + if err != nil { + panic(err) + } + return tx, nil +} + +func (c *Client) TransferOrBurnTokens(ctx context.Context, payer, user solana.PublicKey, nonce NonceAccount, transfers []*TokenTransfer) (*solana.Transaction, error) { + builder := c.buildInitialTxWithNonceAccount(ctx, payer, nonce) + + for _, transfer := range transfers { + if transfer.SolanaAsset { + b, err := c.addTransferSolanaAssetInstruction(ctx, builder, transfer, payer, user) + if err != nil { + return nil, err + } + builder = b + continue + } + + ataAddress := FindAssociatedTokenAddress(user, transfer.Mint, solana.TokenProgramID) + builder.AddInstruction( + token.NewBurnCheckedInstruction( + transfer.Amount, + transfer.Decimals, + ataAddress, + transfer.Mint, + user, + nil, + ).Build(), + ) + } + + return builder.Build() +} + +func (c *Client) addTransferSolanaAssetInstruction(ctx context.Context, builder *solana.TransactionBuilder, transfer *TokenTransfer, payer, source solana.PublicKey) (*solana.TransactionBuilder, error) { + if !transfer.SolanaAsset { + panic(transfer.AssetId) + } + if transfer.AssetId == transfer.ChainId { + src := source + if transfer.Fee { + src = payer + } + builder.AddInstruction( + system.NewTransferInstruction( + transfer.Amount, + src, + transfer.Destination, + ).Build(), + ) + return builder, nil + } + + mintAccount, err := c.RPCGetAccount(ctx, transfer.Mint) + if err != nil { + panic(err) + } + tokenProgram := mintAccount.Value.Owner + + src := FindAssociatedTokenAddress(source, transfer.Mint, tokenProgram) + dst := FindAssociatedTokenAddress(transfer.Destination, transfer.Mint, tokenProgram) + ata, err := c.RPCGetAccount(ctx, dst) + if err != nil { + return nil, err + } + + switch { + case tokenProgram.Equals(solana.TokenProgramID): + if ata == nil || common.CheckTestEnvironment(ctx) { + builder.AddInstruction( + tokenAta.NewCreateInstruction( + payer, + transfer.Destination, + transfer.Mint, + ).Build(), + ) + } + builder.AddInstruction( + token.NewTransferCheckedInstruction( + transfer.Amount, + transfer.Decimals, + src, + transfer.Mint, + dst, + source, + nil, + ).Build(), + ) + case tokenProgram.Equals(solana.Token2022ProgramID): + if ata == nil || common.CheckTestEnvironment(ctx) { + builder.AddInstruction( + NewAta2022CreateInstruction( + payer, + transfer.Destination, + transfer.Mint, + ).Build(), + ) + } + builder.AddInstruction( + NewToken2022TransferCheckedInstruction( + transfer.Amount, + transfer.Decimals, + src, + transfer.Mint, + dst, + source, + nil, + ).Build(), + ) + default: + panic(fmt.Errorf("invalid token program id: %s", tokenProgram.String())) + } + return builder, nil +} + +func (c *Client) getPriorityFeeInstruction(ctx context.Context) *computebudget.Instruction { + if common.CheckTestEnvironment(ctx) { + return computebudget.NewSetComputeUnitPriceInstruction(0).Build() + } + recentFees, err := c.rpcClient.GetRecentPrioritizationFees(ctx, []solana.PublicKey{}) + if err != nil { + panic(err) + } + total := decimal.NewFromInt(0) + for _, fee := range recentFees { + total = total.Add(decimal.NewFromUint64(fee.PrioritizationFee)) + } + fee := total.Div(decimal.NewFromInt(int64(len(recentFees)))).BigInt().Uint64() + return computebudget.NewSetComputeUnitPriceInstruction(fee).Build() +} + +func ExtractTransfersFromTransaction(ctx context.Context, tx *solana.Transaction, meta *rpc.TransactionMeta, exception *solana.PublicKey) ([]*Transfer, error) { + if meta.Err != nil { + panic(fmt.Sprint(meta.Err)) + } + + hash := tx.Signatures[0].String() + msg := tx.Message + + var ( + transfers = []*Transfer{} + innerInstructions = map[uint16][]solana.CompiledInstruction{} + tokenAccounts = map[solana.PublicKey]token.Account{} + owners = []*solana.PublicKey{} + ) + + for _, inner := range meta.InnerInstructions { + innerInstructions[inner.Index] = inner.Instructions + } + + for _, balance := range meta.PreTokenBalances { + if account, err := msg.Account(balance.AccountIndex); err == nil { + tokenAccounts[account] = token.Account{ + Owner: *balance.Owner, + Mint: balance.Mint, + } + if !slices.ContainsFunc(owners, func(owner *solana.PublicKey) bool { + return owner.Equals(*balance.Owner) + }) { + owners = append(owners, balance.Owner) + } + } + } + + for index, ix := range msg.Instructions { + baseIndex := int64(index+1) * 10000 + if transfer := extractTransfersFromInstruction(&msg, ix, tokenAccounts, owners, transfers); transfer != nil { + if exception != nil && exception.String() == transfer.Receiver { + continue + } + transfer.Signature = hash + transfer.Index = baseIndex + transfers = append(transfers, transfer) + } + + for innerIndex, inner := range innerInstructions[uint16(index)] { + if transfer := extractTransfersFromInstruction(&msg, inner, tokenAccounts, owners, transfers); transfer != nil { + if exception != nil && exception.String() == transfer.Receiver { + continue + } + transfer.Signature = hash + transfer.Index = baseIndex + int64(innerIndex) + 1 + transfers = append(transfers, transfer) + } + } + } + + return transfers, nil +} + +func ExtractMintsFromTransaction(tx *solana.Transaction) []string { + var assets []string + for index, ix := range tx.Message.Instructions { + if index == 0 { + continue + } + programKey, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil { + panic(err) + } + accounts, err := ix.ResolveInstructionAccounts(&tx.Message) + if err != nil { + panic(err) + } + + switch programKey { + case solana.TokenProgramID, solana.Token2022ProgramID: + if mint, ok := DecodeMintToken(accounts, ix.Data); ok { + address := mint.GetMintAccount().PublicKey + assets = append(assets, address.String()) + continue + } + } + } + return assets +} + +func GetSignatureIndexOfAccount(tx solana.Transaction, publicKey solana.PublicKey) (int, error) { + index, err := tx.GetAccountIndex(publicKey) + if err == nil { + return int(index), nil + } + if strings.Contains(err.Error(), "account not found") { + return -1, nil + } + return -1, err +} diff --git a/cmd/computer.go b/cmd/computer.go new file mode 100644 index 00000000..6958af00 --- /dev/null +++ b/cmd/computer.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/MixinNetwork/safe/computer" + "github.com/MixinNetwork/safe/config" + "github.com/MixinNetwork/safe/messenger" + "github.com/MixinNetwork/safe/mtg" + "github.com/fox-one/mixin-sdk-go/v2" + "github.com/fox-one/mixin-sdk-go/v2/mixinnet" + "github.com/urfave/cli/v2" +) + +func ComputerBootCmd(c *cli.Context) error { + ctx := context.Background() + + version := c.App.Metadata["VERSION"].(string) + ua := fmt.Sprintf("Mixin Computer (%s)", version) + resty := mixin.GetRestyClient() + resty.SetTimeout(time.Second * 30) + resty.SetHeader("User-Agent", ua) + + mc, err := config.ReadConfiguration(c.String("config"), "computer") + if err != nil { + return err + } + mc.Computer.MTG.GroupSize = 1 + + db, err := mtg.OpenSQLite3Store(mc.Computer.StoreDir + "/mtg.sqlite3") + if err != nil { + return err + } + defer db.Close() + + group, err := mtg.BuildGroup(ctx, db, mc.Computer.MTG) + if err != nil { + return err + } + group.EnableDebug() + group.SetKernelRPC(mc.Computer.MixinRPC) + + s := &mixin.Keystore{ + ClientID: mc.Computer.MTG.App.AppId, + SessionID: mc.Computer.MTG.App.SessionId, + SessionPrivateKey: mc.Computer.MTG.App.SessionPrivateKey, + ServerPublicKey: mc.Computer.MTG.App.ServerPublicKey, + } + client, err := mixin.NewFromKeystore(s) + if err != nil { + return err + } + me, err := client.UserMe(ctx) + if err != nil { + return err + } + key, err := mixinnet.ParseKeyWithPub(mc.Computer.MTG.App.SpendPrivateKey, me.SpendPublicKey) + if err != nil { + return err + } + mc.Computer.MTG.App.SpendPrivateKey = key.String() + + messenger, err := messenger.NewMixinMessenger(ctx, mc.Computer.Messenger()) + if err != nil { + return err + } + + kd, err := computer.OpenSQLite3Store(mc.Computer.StoreDir + "/computer.sqlite3") + if err != nil { + return err + } + defer kd.Close() + computer := computer.NewNode(kd, group, messenger, mc.Computer, client) + computer.Boot(ctx, version) + + if mmc := mc.Computer.MonitorConversationId; mmc != "" { + go MonitorComputer(ctx, computer, client, db, kd, mc.Computer, group, mmc, version) + } + + group.AttachWorker(mc.Computer.AppId, computer) + group.RegisterDepositEntry(mc.Computer.AppId, mtg.DepositEntry{ + Destination: mc.Computer.SolanaDepositEntry, + Tag: "", + }) + group.Run(ctx) + return nil +} diff --git a/cmd/monitor.go b/cmd/monitor.go index b2eb82f4..96d4c582 100644 --- a/cmd/monitor.go +++ b/cmd/monitor.go @@ -13,12 +13,15 @@ import ( "github.com/MixinNetwork/mixin/crypto" "github.com/MixinNetwork/mixin/logger" "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer" + cstore "github.com/MixinNetwork/safe/computer/store" "github.com/MixinNetwork/safe/keeper" kstore "github.com/MixinNetwork/safe/keeper/store" "github.com/MixinNetwork/safe/mtg" "github.com/MixinNetwork/safe/signer" "github.com/fox-one/mixin-sdk-go/v2" "github.com/fox-one/mixin-sdk-go/v2/mixinnet" + "github.com/shopspring/decimal" ) type UserStore interface { @@ -227,6 +230,122 @@ func bundleKeeperState(ctx context.Context, mdb *mtg.SQLite3Store, store *kstore return state, nil } +func MonitorComputer(ctx context.Context, node *computer.Node, mixin *mixin.Client, mdb *mtg.SQLite3Store, store *cstore.SQLite3Store, conf *computer.Configuration, group *mtg.Group, conversationId, version string) { + logger.Printf("MonitorComputer(%s, %s)", group.GenesisId(), conversationId) + startedAt := time.Now() + + app := conf.MTG.App + conv, err := bot.ConversationShow(ctx, conversationId, &bot.SafeUser{ + UserId: app.AppId, + SessionId: app.SessionId, + SessionPrivateKey: app.SessionPrivateKey, + }) + if err != nil { + panic(err) + } + + for { + time.Sleep(1 * time.Minute) + msg, err := bundleComputerState(ctx, node, mixin, mdb, store, conf, group, startedAt, version) + if err != nil { + logger.Verbosef("Monitor.bundleComputerState() => %v", err) + continue + } + postMessages(ctx, store, conv, conf.MTG, msg, conf.ObserverId) + time.Sleep(30 * time.Minute) + } +} + +func bundleComputerState(ctx context.Context, node *computer.Node, mixin *mixin.Client, mdb *mtg.SQLite3Store, store *cstore.SQLite3Store, conf *computer.Configuration, grp *mtg.Group, startedAt time.Time, version string) (string, error) { + state := "🧱🧱🧱🧱🧱 Computer 🧱🧱🧱🧱🧱\n" + state = state + fmt.Sprintf("⏲️ Run time: %s\n", time.Since(startedAt).String()) + state = state + fmt.Sprintf("⏲️ Group: %s 𝕋%d\n", mixinnet.HashMembers(grp.GetMembers())[:16], grp.GetThreshold()) + + state = state + "\n𝗠𝙏𝗚\n" + req, err := store.ReadLatestRequest(ctx) + if err != nil { + return "", err + } else if req != nil { + state = state + fmt.Sprintf("🎆 Latest request: %x\n", req.MixinHash[:8]) + } + + tl, _, err := mdb.ListTransactions(ctx, mtg.TransactionStateInitial, 1000) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("🫰 Initial Transactions: %d\n", len(tl)) + tl, _, err = mdb.ListTransactions(ctx, mtg.TransactionStateSigned, 1000) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("🫰 Signed Transactions: %d\n", len(tl)) + tl, _, err = mdb.ListTransactions(ctx, mtg.TransactionStateSnapshot, 1000) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("🫰 Snapshot Transactions: %d\n", len(tl)) + tl, err = mdb.ListConfirmedWithdrawalTransactionsAfter(ctx, time.Time{}, 1000) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("🫰 Withdrawal Transactions: %d\n", len(tl)) + + state = state + "\n𝗔𝙋𝗣\n" + uc, err := store.CountUsers(ctx) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("🔑 Registered Users: %d\n", uc) + tc, err := store.CountUserSystemCallByState(ctx, common.RequestStateInitial) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💷 Initial Transactions: %d\n", tc) + tc, err = store.CountUserSystemCallByState(ctx, common.RequestStatePending) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💶 Pending Transactions: %d\n", tc) + tc, err = store.CountUserSystemCallByState(ctx, common.RequestStateDone) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💵 Done Transactions: %d\n", tc) + tc, err = store.CountUserSystemCallByState(ctx, common.RequestStateFailed) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💸 Failed Transactions: %d\n", tc) + + state = state + "\nBalances\n" + _, c, err := common.SafeAssetBalance(ctx, mixin, []string{conf.MTG.App.AppId}, 1, conf.AssetId) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💍 MSST outputs: %d\n", c) + if conf.MTG.App.AppId == conf.ObserverId { + xinBalance, err := common.SafeAssetBalanceUntilSufficient(ctx, node.SafeUser(), mtg.StorageAssetId) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💍 XIN Balance: %s\n", xinBalance.String()) + solBalance, err := common.SafeAssetBalanceUntilSufficient(ctx, node.SafeUser(), common.SafeSolanaChainId) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💍 SOL Balance: %s\n", solBalance.String()) + + balance, err := node.GetPayerBalance(ctx) + if err != nil { + return "", err + } + state = state + fmt.Sprintf("💍 Payer %s Balance: %s SOL\n", node.SolanaPayer(), decimal.NewFromUint64(balance).Div(decimal.New(1, 9)).String()) + } + + state = state + fmt.Sprintf("🦷 Binary version: %s", version) + return state, nil +} + func postMessages(ctx context.Context, store UserStore, conv *bot.Conversation, conf *mtg.Configuration, msg, observer string) { app := conf.App var messages []*bot.MessageRequest diff --git a/computer/README.md b/computer/README.md index a9a0a8e7..de048627 100644 --- a/computer/README.md +++ b/computer/README.md @@ -1,24 +1,14 @@ -# Safe Computer +# Safe Trusted Computer Safe Computer is a decentralized computer for financial computing. This computer runs inside Mixin Safe, and utilizes existing mature blockchains as the runtime. ## Spec -Send transactions to the computer group, each transaction costs some XIN, could be multiple outputs or a XIN transaction references the previous transactions. +Send a XIN transaction to the computer group, and the XIN transaction could reference the previous transactions. -It's better to increase the maximum references count in Mixin Kernel. +With transaction extra to determine the 2 operation. -With transaction extra to determine the 3 operation. - -The extra must be pure bytes, or base64 URL encoding. - -## Start a Process - -Assign a unique unsigned integer PID for an existing program in a supported runtime. - -0 | ADDRESS - -The PID is bigger than 2^24. Smaller PID is the system process. +The extra must be base64 URL encoding. ## Add a User @@ -26,58 +16,55 @@ Assign a unique unsigned integer UID for a MIX address. Also makes a user accoun 1 | MIX -The UID is bigger than 2^48. Smaller UID is the system user. But the UID is never smaller than 2^32. Thus make the PID and UID globally unique in the system. +The UID is bigger than 2^48. Smaller UID is the system user. But the UID is never smaller than 2^32. ## Make System Calls -A transaction could make multiple system calls to multiple processes. +One transaction could make one system call. -2 | UID(uint64) | -PID(uint32) | CALLDATA(0:LEN-PREFIXED-BYTES OR 1:HASH) | -PID(uint32) | CALLDATA(0:LEN-PREFIXED-BYTES OR 1:HASH) | -... +2 | UID(uint64) | CALL ID (uuid) | Skip Flag (byte) | FEE ID (optional, uuid) UID is the asset recipient, and a invalid UID or non existing UID will lose the assets. -It's possible to deploy a program in a supported runtime, just make a system call to the runtime PID, with the program bytes as the CALLDATA. This is undefined yet, need to discuss. Better not doing this. +CALL ID is a uuid and could be specified by creator. -## Solana Runtime +When Skip Flag is set to 1, postprocess system call would not be proposed to refund rest assets or transfer newly received assets to MIX account. + +When FEE ID is provided and extra amount of XIN is sent to computer, the same worth of SOL would be transferred to user account on Solana for rents to create accounts during the system call. -A MIX account wants to add BTC/SOL pool to the Raydium program, the PID is 3278432. +The bytes of Solana transaction should be saved in a storage transaction, and the storage transaction must be referenced by the XIN transaction to make System Call. -1. Add a User and query the HTTP API to get the UID, e.g. 432483921937. Now there must already be the solana user account. -2. Send three transactions to the computer group. BTC, SOL, and XIN references the two transactions, with extra: -3. 2 | 432483921937 | 3278432 | BTC/SOL WHAT WHAT +## Solana Runtime -The group receives the XIN transaciton and will check the fee is enough, then make system calls according to the extra. +A MIX account wants to create BTC/SOL pool to the Raydium program. -The group has a group account, controlled by the multisig. The group withdraws SOL to the group account. After the SOL transaction is confirmed by Solana blockchain. The observer sends a notification to the group. Then the group makes a transaction with the following instructions: +1. Send XIN transaction with extra to computer group to add a User. Then query the HTTP API to get the UID, e.g. 432483921937, and user account address. +2. Fetch fee payer address, nonce account address and nonce hash from HTTP API, then build Solana transaction with this fee payer address, nonce advance instruction and create pool instructions. +3. Fetch fee id and amount of XIN for the same worth of SOL to pay the rents of created account needed in System Call. +4. Send the XIN transaction with 0.001 XIN for operation and extra amount of XIN for rents, and it should reference a storage transaction of Solana transaction, a BTC transaction and a SOL transaction for liquidity. -1. group account mint BTC to the user account -2. group account sends SOL to the user account -3. user account adds SOL and BTC to Raydium -4. advance nonce, this nonce account is controlled by the group too +The group receives the XIN transaction and will check referenced transactions, the amount of received fee, the payer and the nonce account of storage Solana transaction, then build the prepare System Call to transfer SOL and mint BTC from group account to user account in preparation for System Call created by user. And the MPC would start to generate the signatures for these two System Calls. The prepare System Call created by observer includes the following instructions: -Then each group members sign the transaction in one go, and combines the signature and sends to the network. To combine the signature, each node sends a storage transaction, with 0.0000001XIN output to the group. And there is a member signature to the data for the group to check. If the transaction extra is small enough, we just sends a normal XIN transaction, without storage output. +1. advance nonce +2. transfer SOL for rent to user account +3. transfer SOL for liquidity to user account +4. create the associated token address of user account if necessary +5. mint spl token of BTC to user account -After the transaction confirmed, there should be LP tokens to the user account? To make this more complicated, the transaction also refunds some BTC because of slippage. We have an observer node to scan the Solana blockchain and finds this LP and BTC transaction to the user account, and sends a notification to the computer group. The computer group will make a transaction with the following instructions: +The user and the group both have an account on Solana Chain, and are both controlled by the MPC multisig. The group withdraws SOL to the group account at first. After the SOL withdrawal being confirmed by Solana blockchain, the observer sends a notification to the group. -1. user account transfer LP token to the group account deposit address in Mixin. -2. user account burns the BTC token. -3. advance nonce. +Then observer will send the prepare System Call and the user System Call in order with the generated signatures. After the two transaction are both confirmed, the observer should update the hashes of used nonce accounts and notify the group with a post-process System Call to burn the rest amount of BTC, transfer the rest amount of SOL and transfer the received LP token to the deposit entry of group. -Then sign the transaction in one go, and broadcast, and marking the transaction in pending state. The observer finds the transaction successful, then send a notification to the group. The group finds the transaction failed or succesful. If failed, then mark the transaction failed and do nothing. The observer could send a retry notification, or send a refund transaction so that the group will just refund everything to the MIX address. If successful, then the group will sends BTC to the user MIX account, but not the LP token. +After the group mpc generates the signature of post-process signature, the observer node would send it to the Solana Network and notify the group when it is confirmed. The group would refund the same amount of BTC to the MIX account, and transfer the SOL and LP token to the MIX account after receiving the deposits. -Whenever the group account received a mixin deposit transaction, in this case, the LP token, the observer will send a notification to the group, and the group will just send the LP token to the user account corresponding UID MIX account. +In addition, the observer keeps scanning the Solana blocks, and would create a system call to transfer deposit to the user account. Whenever the group receives a mixin deposit transaction, in this case, the LP token, the group will just send the LP token to the MIX account. The observer is one member of the computer group. And any member could be the observer. There could be multiple observers, and the observer notifications could be duplicated, but the group could identify it because the notification is just a Solana transaction hash. -## Concensus +## Consensus It's very important that the computer group never makes any transactions based on external environment. It will only makes transactions or signatures based on Mixin output, either from users or from observers. -The observer sends notifications with Solana transactions, it's external information for the group, but this computer has an assumption that Solana will not fork, a Solana transaction is finalized and should be there. So an observer notified solana transaction is considered determinstic fact, but the tranaction must be an observer notification at first. So if the observer is honest, then all group members will find the same transaction in the Solana blockchain, and if can't find it, the group member should just panic. Then it means either the observer is adversary or the Soalan blockchain is broken. +The observer sends notifications with Solana transactions, it's external information for the group, but this computer has an assumption that Solana will not fork, a Solana transaction is finalized and should be there. So an observer notified solana transaction is considered deterministic fact, but the transaction must be an observer notification at first. So if the observer is honest, then all group members will find the same transaction in the Solana blockchain, and if can't find it, the group member should just panic. Then it means either the observer is adversary or the Soalan blockchain is broken. It's also very important that the group account in Solana or user accounts, they don't pay any fee, the fee should be paid by the fee account, thus ensure the group and user accounts balance are always valid. - -Because of the transaction size limit of Solana blockchain, we make a 4/7 MTG for the computer. We also must use the address lookup table and version 0 transaction, just add the 7 members and the group account to the lookup table? Then change the authority or signer of the lookup table to the group account itself? diff --git a/computer/assets/favicon.ico b/computer/assets/favicon.ico new file mode 100644 index 00000000..74d74844 Binary files /dev/null and b/computer/assets/favicon.ico differ diff --git a/computer/assets/mark.png b/computer/assets/mark.png new file mode 100644 index 00000000..b82c377b Binary files /dev/null and b/computer/assets/mark.png differ diff --git a/computer/computer_test.go b/computer/computer_test.go index 0ad4df95..0cbb6b55 100644 --- a/computer/computer_test.go +++ b/computer/computer_test.go @@ -1 +1,779 @@ package computer + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "math/big" + "os" + "strings" + "testing" + "time" + + "github.com/MixinNetwork/bot-api-go-client/v3" + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/multi-party-sig/pkg/party" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/gagliardetto/solana-go" + "github.com/gofrs/uuid/v5" + "github.com/pelletier/go-toml" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +var sequence uint64 = 5000000 + +func TestComputer(t *testing.T) { + require := require.New(t) + ctx, nodes, mds := testPrepare(require) + + testObserverRequestGenerateKey(ctx, require, nodes) + testObserverRequestCreateNonceAccount(ctx, require, nodes) + testObserverSetPriceParams(ctx, require, nodes) + testObserverUpdateNetworInfo(ctx, require, nodes) + testObserverDeployAsset(ctx, require, nodes) + + user := testUserRequestAddUsers(ctx, require, nodes) + call := testUserRequestSystemCall(ctx, require, nodes, mds, user) + testConfirmWithdrawal(ctx, require, nodes, call) + postprocess := testObserverConfirmMainCall(ctx, require, nodes, call) + testObserverConfirmPostProcessCall(ctx, require, nodes, postprocess) + + node := nodes[0] + err := node.store.WriteFailedCallIfNotExist(ctx, call, "test-error") + require.Nil(err) + reason, err := node.store.ReadFailReason(ctx, call.RequestId) + require.Nil(err) + require.Equal("test-error", reason) +} + +func testObserverConfirmPostProcessCall(ctx context.Context, require *require.Assertions, nodes []*Node, sub *store.SystemCall) { + node := nodes[0] + err := node.store.UpdateNonceAccount(ctx, sub.NonceAccount, "6c8hGTPpTd4RMbYyM3wQgnwxZbajKhovhfDgns6bvmrX", sub.RequestId) + require.Nil(err) + nonce, err := node.store.ReadNonceAccount(ctx, sub.NonceAccount) + require.Nil(err) + require.Equal("6c8hGTPpTd4RMbYyM3wQgnwxZbajKhovhfDgns6bvmrX", nonce.Hash) + require.False(nonce.CallId.Valid) + require.Equal(sub.RequestId, nonce.UpdatedBy.String) + + id := uuid.Must(uuid.NewV4()).String() + signature := solana.MustSignatureFromBase58("5s3UBMymdgDHwYvuaRdq9SLq94wj5xAgYEsDDB7TQwwuLy1TTYcSf6rF4f2fDfF7PnA9U75run6r1pKm9K1nusCR") + extra := []byte{FlagConfirmCallSuccess, 1} + extra = append(extra, signature[:]...) + for _, node := range nodes { + out := testBuildObserverRequest(node, id, OperationTypeConfirmCall, extra) + testStep(ctx, require, node, out) + + sub, err := node.store.ReadSystemCallByRequestId(ctx, sub.RequestId, common.RequestStateDone) + require.Nil(err) + require.NotNil(sub) + call, err := node.store.ReadSystemCallByRequestId(ctx, sub.Superior, common.RequestStateDone) + require.Nil(err) + require.NotNil(call) + + ar, _, err := node.store.ReadActionResult(ctx, id, id) + require.Nil(err) + require.Len(ar.Transactions, 1) + require.Equal(common.SafeLitecoinChainId, ar.Transactions[0].AssetId) + } +} + +func testObserverConfirmMainCall(ctx context.Context, require *require.Assertions, nodes []*Node, call *store.SystemCall) *store.SystemCall { + node := nodes[0] + err := node.store.UpdateNonceAccount(ctx, call.NonceAccount, "E9esweXgoVfahhRvpWR4kefZXR54qd82ZGhVTbzQtCoX", call.RequestId) + require.Nil(err) + nonce, err := node.store.ReadNonceAccount(ctx, call.NonceAccount) + require.Nil(err) + require.Equal("E9esweXgoVfahhRvpWR4kefZXR54qd82ZGhVTbzQtCoX", nonce.Hash) + require.Equal(call.RequestId, nonce.UpdatedBy.String) + require.False(nonce.CallId.Valid) + require.False(nonce.Mix.Valid) + + cid := common.UniqueId(call.RequestId, "post-process") + err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) + require.Nil(err) + stx := node.CreatePostProcessTransaction(ctx, call, nonce, nil, nil) + require.NotNil(stx) + raw, err := stx.MarshalBinary() + require.Nil(err) + + id := uuid.Must(uuid.NewV4()).String() + signatures := []solana.Signature{ + solana.MustSignatureFromBase58("2tPHv7kbUeHRWHgVKKddQqXnjDhuX84kTyCvRy1BmCM4m4Fkq4vJmNAz8A7fXqckrSNRTAKuPmAPWnzr5T7eCChb"), + solana.MustSignatureFromBase58("39XBTQ7v6874uQb3vpF4zLe2asgNXjoBgQDkNiWya9ZW7UuG6DgY7kP4DFTRaGUo48NZF4qiZFGs1BuWJyCzRLtW"), + } + extra := []byte{FlagConfirmCallSuccess} + extra = append(extra, byte(len(signatures))) + for _, sig := range signatures { + extra = append(extra, sig[:]...) + } + extra = attachSystemCall(extra, cid, raw) + + var postprocess *store.SystemCall + out := testBuildObserverRequest(node, id, OperationTypeConfirmCall, extra) + for _, node := range nodes { + go testStep(ctx, require, node, out) + } + testObserverRequestSignSystemCall(ctx, require, nodes, cid) + for _, node := range nodes { + main, err := node.store.ReadSystemCallByRequestId(ctx, call.RequestId, common.RequestStateDone) + require.Nil(err) + require.NotNil(main) + sub, err := node.store.ReadSystemCallByRequestId(ctx, cid, common.RequestStatePending) + require.Nil(err) + require.NotNil(sub) + require.Equal(main.RequestId, sub.Superior) + require.Equal(store.CallTypePostProcess, sub.Type) + require.Len(sub.GetWithdrawalIds(), 0) + require.True(sub.Signature.Valid) + require.True(sub.RequestSignerAt.Valid) + postprocess = sub + + uid := main.UserIdFromPublicPath() + os, err := node.store.ListUserOutputsByHashAndState(ctx, uid, "a8eed784060b200ea7f417309b12a33ced8344c24f5cdbe0237b7fc06125f459", common.RequestStateDone) + require.Nil(err) + require.Len(os, 1) + os, err = node.store.ListUserOutputsByHashAndState(ctx, uid, "01c43005fd06e0b8f06a0af04faf7530331603e352a11032afd0fd9dbd84e8ee", common.RequestStateDone) + require.Nil(err) + require.Len(os, 1) + } + return postprocess +} + +func testConfirmWithdrawal(ctx context.Context, require *require.Assertions, nodes []*Node, call *store.SystemCall) { + node := nodes[0] + withdrawal := &store.ConfirmedWithdrawal{ + Hash: "jmHyRpKEuc1PgDjDaqaQqo9GpSM3pp9PhLgwzqpfa2uUbtRYJmbKtWp4onfNFsbk47paBjxz1d6s9n56Y8Na9Hp", + TraceId: call.GetWithdrawalIds()[0], + CallId: call.RequestId, + CreatedAt: time.Now(), + } + err := node.store.WriteConfirmedWithdrawal(ctx, withdrawal) + require.Nil(err) + unconfirmed, err := node.store.CheckUnconfirmedWithdrawals(ctx, call) + require.Nil(err) + require.False(unconfirmed) +} + +func testUserRequestSystemCall(ctx context.Context, require *require.Assertions, nodes []*Node, mds []*mtg.SQLite3Store, user *store.User) *store.SystemCall { + node := nodes[0] + conf := node.conf + nonce, err := node.store.ReadNonceAccount(ctx, "DaJw3pa9rxr25AT1HnQnmPvwS4JbnwNvQbNLm8PJRhqV") + require.Nil(err) + require.False(nonce.Mix.Valid) + require.False(nonce.CallId.Valid) + err = node.store.LockNonceAccountWithMix(ctx, nonce.Address, user.MixAddress) + require.Nil(err) + + sequence += 10 + h1, _ := crypto.HashFromString("a8eed784060b200ea7f417309b12a33ced8344c24f5cdbe0237b7fc06125f459") + _, err = testWriteOutputForNodes(ctx, mds, conf.AppId, common.SafeLitecoinChainId, h1.String(), "", sequence, decimal.RequireFromString("0.01")) + require.Nil(err) + oid1, err := uuid.NewV4() + require.Nil(err) + extra := user.IdBytes() + out1 := testBuildUserRequest(node, oid1.String(), h1.String(), "0.01", common.SafeLitecoinChainId, OperationTypeUserDeposit, extra, nil, nil) + sequence += 10 + h2, _ := crypto.HashFromString("01c43005fd06e0b8f06a0af04faf7530331603e352a11032afd0fd9dbd84e8ee") + _, err = testWriteOutputForNodes(ctx, mds, conf.AppId, common.SafeSolanaChainId, h2.String(), "", sequence, decimal.RequireFromString("0.005")) + require.Nil(err) + oid2, err := uuid.NewV4() + require.Nil(err) + out2 := testBuildUserRequest(node, oid2.String(), h2.String(), "0.005", common.SafeSolanaChainId, OperationTypeUserDeposit, extra, nil, nil) + for _, node := range nodes { + err = node.store.WriteProperty(ctx, h1.String(), "7777000546dbd75ed416c82652554a2fd257df3adb5d8c68726db6631bf1300e7aa36f4100013db24d1350f18126b0f93309913d237fcb870f63fb42cafb3a7d0202aca77bd200000000000000000001000000030f4240000103551f38d1ae2002e06892803b57c838012123911681dc567564e63042c3377690b6636bc74fa394d9122c6af4415d4d151c9671eb82d43c096ea01635bc177f0003fffe01000000000000007854554638593251794e5745334d6a5174593249354d7930304d324d784c546c6d4e6a6774595746695a6d4e6a4d7a4d344f446334664656515245465552563950556b5246556e786d4e446730593255794f53307a596d597a4c5451354d5755744f44677a5a6930334e6d4935596a68694e6a526a4d32453d00010001000052bf7fb6ce4e61527b1cec54d8b705b66c24876d7f53672f9f398c30c20e57136fe4853a40ae7b02a81f038055a09a1e3b0034c62a06960934c38db41701c60b") + require.Nil(err) + err = node.store.WriteProperty(ctx, h2.String(), "77770005481360491383ebd4f0f97543f3440313b48b8fd06dcfa5a0c2cabe4252d3a8eb000130ae0a78947f751fc7be11674c6bd93492069b5cec475c22a4afa382ed543f4c000000000000000000020000000307a12000029a0f3710baf7a8d1695b7abdabc360a79e05a389767073defac81cf9822d75e232d53fe83b77deebbe4da8eddbb88c8e3eae4ebfcd7ae5f17670445ebd84122bfb02ce99af492d1980209ed90919379d2cd2e64836383f60fb3ed10a58043b180003fffe020000000000026b4700012c1d4c257f92cc8dd39e2feb70c14708b593c122cb77714bb5fd5bd55753f96e5fed9e5daeb367bcacbc3e68bb3c147b443fc6e3a40018dc1677c538abf55f7a0003fffe010000000000000000000100010000b4cf2a72adf8014550860fdc2e078163925f6b6baef6086e4b56d7e9f1beccffac0fd298131419f9aa3596e2efd466e35d06fc764491f5c31ac2e464ffaab90b") + require.Nil(err) + + testStep(ctx, require, node, out1) + testStep(ctx, require, node, out2) + + os, err := node.store.ListUserOutputsByHashAndState(ctx, user.UserId, h1.String(), common.RequestStateInitial) + require.Nil(err) + require.Len(os, 1) + os, err = node.store.ListUserOutputsByHashAndState(ctx, user.UserId, h2.String(), common.RequestStateInitial) + require.Nil(err) + require.Len(os, 1) + } + + solAmount := decimal.RequireFromString("0.23456789") + fee, err := node.store.ReadLatestFeeInfo(ctx) + require.Nil(err) + ratio := decimal.RequireFromString(fee.Ratio) + xinAmount := solAmount.Div(ratio).RoundCeil(8).String() + require.Equal("0.28271639", xinAmount) + xinFee := decimal.RequireFromString(xinAmount) + + id := uuid.Must(uuid.NewV4()).String() + refs := testStorageSystemCall(ctx, nodes, common.DecodeHexOrPanic("02000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000810cdc56c8d087a301b21144b2ab5e1286b50a5d941ee02f62488db0308b943d2d64375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca85002953f9517566994f5066c9478a5e6d0466906e7d844b2d971b2e4f86ff72561c6d6405387e0deff4ac3250e4e4d1986f1bc5e805edd8ca4c48b73b92441afdc070b84fed2e0ca7ecb2a18e32bf10885151641616b3fe4447557683ee699247e1f9cbad4af79952644bd80881b3934b3e278ad2f4eeea3614e1c428350d905eac4ecf6994777d4d13d8bd64679ac9e173a29ea40653734b52eee914ddc43c820f424071d460ef6501203e6656563c4add1638164d5eba1dee13e9085fb60036f98f10000000000000000000000000000000000000000000000000000000000000000816e66630c3bb724dc59e49f6cc4306e603a6aacca06fa3e34e2b40ad5979d8da5d5ca9e04cf5db590b714ba2fe32cb159133fc1c192b72257fd07d39cb0401ec4db1d1f598d6a8197daf51b68d7fc0ef139c4dec5a496bac9679563bd3127db069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f0000000000106a7d517192c568ee08a845f73d29788cf035c3145b21ab344d8062ea940000006a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a0000000006ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a90ff0530009fc7a19cf8d8d0257f1dc2d478f1368aa89f5e546c6e12d8a4015ec020803050d0004040000000a0d0109030c0b020406070f0f080e20e992d18ecf6840bcd564b7ff16977c720000000000000000b992766700000000")) + refs = append(refs, []crypto.Hash{h1, h2}...) + + hash := "d3b2db9339aee4acb39d0809fc164eb7091621400a9a3d64e338e6ffd035d32f" + extra = user.IdBytes() + extra = append(extra, uuid.Must(uuid.FromString(id)).Bytes()...) + extra = append(extra, FlagWithPostProcess) + extra = append(extra, uuid.Must(uuid.FromString(fee.Id)).Bytes()...) + out := testBuildUserRequest(node, id, hash, "0.001", mtg.StorageAssetId, OperationTypeSystemCall, extra, refs, &xinFee) + for _, node := range nodes { + testStep(ctx, require, node, out) + call, err := node.store.ReadSystemCallByRequestId(ctx, id, common.RequestStateInitial) + require.Nil(err) + require.Equal(id, call.RequestId) + require.Equal(out.OutputId, call.Superior) + require.Equal(store.CallTypeMain, call.Type) + require.Equal(hex.EncodeToString(user.FingerprintWithPath()), call.Public) + require.False(call.Signature.Valid) + require.True(call.RequestSignerAt.Valid) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, user.UserId, call.RequestHash, common.RequestStatePending) + require.Nil(err) + require.Len(os, 2) + } + + cs, err := node.store.ListUnconfirmedSystemCalls(ctx) + require.Nil(err) + require.Len(cs, 1) + c := cs[0] + nonce, err = node.store.ReadNonceAccount(ctx, c.NonceAccount) + require.Nil(err) + require.True(nonce.LockedByUserOnly()) + user, err = node.store.ReadUser(ctx, c.UserIdFromPublicPath()) + require.Nil(err) + require.Equal(user.MixAddress, nonce.Mix.String) + err = node.store.OccupyNonceAccountByCall(ctx, c.NonceAccount, c.RequestId) + require.Nil(err) + nonce, err = node.store.ReadNonceAccount(ctx, c.NonceAccount) + require.Nil(err) + require.True(nonce.Valid(c.RequestId)) + + nonce, err = node.store.ReadSpareNonceAccount(ctx) + require.Nil(err) + require.Equal("7ipVMFwwgbvyum7yniEHrmxtbcpq6yVEY8iybr7vwsqC", nonce.Address) + require.Equal("8uL2Fwc3WNnM7pYkXjn1sxHXGTBmWrB7HpNAtKuuLbEG", nonce.Hash) + extraFee, err := node.getSystemCallFeeFromXIN(ctx, c) + require.Nil(err) + feeActual := decimal.RequireFromString(extraFee.Amount) + require.True(feeActual.Cmp(solAmount) >= 0) + stx, err := node.CreatePrepareTransaction(ctx, c, nonce, extraFee) + require.Nil(err) + require.NotNil(stx) + raw, err := stx.MarshalBinary() + require.Nil(err) + cid := common.UniqueId(c.RequestId, "prepare") + + id = uuid.Must(uuid.NewV4()).String() + extra = []byte{ConfirmFlagNonceAvailable} + extra = append(extra, uuid.Must(uuid.FromString(c.RequestId)).Bytes()...) + extra = attachSystemCall(extra, cid, raw) + + out = testBuildObserverRequest(node, id, OperationTypeConfirmNonce, extra) + for _, node := range nodes { + go testStep(ctx, require, node, out) + } + time.Sleep(10 * time.Second) + for _, node := range nodes { + call, err := node.store.ReadSystemCallByRequestId(ctx, c.RequestId, common.RequestStatePending) + require.Nil(err) + require.Len(call.GetWithdrawalIds(), 1) + c = call + call, err = node.store.ReadSystemCallByRequestId(ctx, cid, common.RequestStatePending) + require.Nil(err) + require.True(call.WithdrawalTraces.Valid) + } + testObserverRequestSignSystemCall(ctx, require, nodes, cid) + testObserverRequestSignSystemCall(ctx, require, nodes, c.RequestId) + return c +} + +func testUserRequestAddUsers(ctx context.Context, require *require.Assertions, nodes []*Node) *store.User { + start := big.NewInt(0).Add(store.StartUserId, big.NewInt(1)) + var user *store.User + var as []string + id := uuid.Must(uuid.NewV4()).String() + for _, node := range nodes { + uid := common.UniqueId(id, "user1") + mix := bot.NewUUIDMixAddress([]string{uid}, 1) + out := testBuildUserRequest(node, id, "", "0.001", mtg.StorageAssetId, OperationTypeAddUser, []byte(mix.String()), nil, nil) + testStep(ctx, require, node, out) + user1, err := node.store.ReadUserByMixAddress(ctx, mix.String()) + require.Nil(err) + require.Equal(mix.String(), user1.MixAddress) + require.Equal(start.String(), user1.UserId) + require.Equal("4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295", user1.Public) + + _, share, err := node.store.ReadKeyByFingerprint(ctx, hex.EncodeToString(common.Fingerprint(user1.Public))) + require.Nil(err) + public, _ := node.deriveByPath(share, user1.IdBytes()) + require.Equal(solana.PublicKeyFromBytes(public).String(), user1.ChainAddress) + as = append(as, user1.ChainAddress) + user = user1 + + id2 := common.UniqueId(id, "second") + uid = common.UniqueId(id, "user2") + mix = bot.NewUUIDMixAddress([]string{uid}, 1) + out = testBuildUserRequest(node, id2, "", "0.001", mtg.StorageAssetId, OperationTypeAddUser, []byte(mix.String()), nil, nil) + testStep(ctx, require, node, out) + user2, err := node.store.ReadUserByMixAddress(ctx, mix.String()) + require.Nil(err) + require.Equal(mix.String(), user2.MixAddress) + require.Equal(big.NewInt(0).Add(start, big.NewInt(1)).String(), user2.UserId) + require.Equal("4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295", user2.Public) + as = append(as, user2.ChainAddress) + + us, err := node.store.ListNewUsersAfter(ctx, time.Time{}) + require.Nil(err) + require.Len(us, 2) + } + + c, err := nodes[0].store.CheckInternalAccounts(ctx, []string{"1", "2"}) + require.Nil(err) + require.Equal(0, c) + c, err = nodes[0].store.CheckInternalAccounts(ctx, as) + require.Nil(err) + require.Equal(2, c) + as = append(as, "1") + c, err = nodes[0].store.CheckInternalAccounts(ctx, as) + require.Nil(err) + require.Equal(2, c) + return user +} + +func testObserverRequestCreateNonceAccount(ctx context.Context, require *require.Assertions, nodes []*Node) { + as := [][2]string{ + {"DaJw3pa9rxr25AT1HnQnmPvwS4JbnwNvQbNLm8PJRhqV", "25DfFJbUsDMR7rYpieHhK7diWB1EuWkv5nB3F6CzNFTR"}, + {"7ipVMFwwgbvyum7yniEHrmxtbcpq6yVEY8iybr7vwsqC", "8uL2Fwc3WNnM7pYkXjn1sxHXGTBmWrB7HpNAtKuuLbEG"}, + {"ByaBrgG365HHJfMiybAg3sJfFuyj6oEou2cA6Cs4DfT6", "GPr2BFAJEdYeevsehok3UABvAHS6E6CXi36HNYeEbggo"}, + testGenerateRandNonceAccount(require), + } + node := nodes[0] + + for _, nonce := range as { + err := node.store.WriteNonceAccount(ctx, nonce[0], nonce[1]) + require.Nil(err) + } + count, err := node.store.CountNonceAccounts(ctx) + require.Nil(err) + require.Equal(4, count) +} + +func testObserverUpdateNetworInfo(ctx context.Context, require *require.Assertions, nodes []*Node) { + id := "1055985c-5759-3839-b5b5-977915ac424d" + node := nodes[0] + + fee, err := node.store.ReadLatestFeeInfo(ctx) + require.Nil(err) + require.Nil(fee) + + xinPrice := decimal.RequireFromString("105.23") + solPrice := decimal.RequireFromString("126.83") + ratio := xinPrice.Div(solPrice) + require.Equal("0.8296932902310179", ratio.String()) + + err = node.store.WriteFeeInfo(ctx, id, ratio) + require.Nil(err) + fee, err = node.store.ReadLatestFeeInfo(ctx) + require.Nil(err) + require.NotNil(fee) + require.Equal(ratio.String(), fee.Ratio) +} + +func testObserverSetPriceParams(ctx context.Context, require *require.Assertions, nodes []*Node) { + for _, node := range nodes { + params, err := node.store.ReadLatestOperationParams(ctx, time.Now().UTC()) + require.Nil(err) + require.Nil(params) + + amount := decimal.RequireFromString(node.conf.OperationPriceAmount) + logger.Printf("node.sendPriceInfo(%s, %s)", node.conf.OperationPriceAssetId, amount) + amount = amount.Mul(decimal.New(1, 8)) + if amount.Sign() <= 0 || !amount.IsInteger() || !amount.BigInt().IsInt64() { + panic(node.conf.OperationPriceAmount) + } + id := common.UniqueId("OperationTypeSetOperationParams", node.conf.OperationPriceAssetId) + id = common.UniqueId(id, amount.String()) + extra := uuid.Must(uuid.FromString(node.conf.OperationPriceAssetId)).Bytes() + extra = binary.BigEndian.AppendUint64(extra, uint64(amount.IntPart())) + + out := testBuildObserverRequest(node, id, OperationTypeSetOperationParams, extra) + testStep(ctx, require, node, out) + + params, err = node.store.ReadLatestOperationParams(ctx, time.Now().UTC()) + require.Nil(err) + require.NotNil(params) + require.Equal(node.conf.OperationPriceAssetId, params.OperationPriceAsset) + require.Equal(node.conf.OperationPriceAmount, params.OperationPriceAmount.String()) + } +} + +func testObserverDeployAsset(ctx context.Context, require *require.Assertions, nodes []*Node) { + node := nodes[0] + + err := node.store.WriteExternalAssets(ctx, []*store.ExternalAsset{ + { + AssetId: common.SafeLitecoinChainId, + CreatedAt: time.Now().UTC(), + }, + }) + require.Nil(err) + id, _, assets, err := node.CreateMintsTransaction(ctx, []string{common.SafeLitecoinChainId}) + require.Nil(err) + as, err := node.store.ListUndeployedAssets(ctx) + require.Nil(err) + require.Len(as, 1) + + var extra []byte + extra = append(extra, byte(len(assets))) + for _, asset := range assets { + extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) + extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) + } + + out := testBuildObserverRequest(node, id, OperationTypeDeployExternalAssets, extra) + for _, node := range nodes { + go testStep(ctx, require, node, out) + } + time.Sleep(10 * time.Second) + + err = node.store.MarkExternalAssetDeployed(ctx, assets, "MBsH9LRbrx4u3kMkFkGuDyxjj3Pio55Puwv66dtR2M3CDfaR7Ef7VEKHDGM7GhB3fE1Jzc7k3zEZ6hvJ399UBNi") + require.Nil(err) + as, err = node.store.ListUndeployedAssets(ctx) + require.Nil(err) + require.Len(as, 0) + for _, node := range nodes { + a, err := node.store.ReadDeployedAsset(ctx, common.SafeLitecoinChainId) + require.Nil(err) + require.Equal("EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8", a.Address) + } +} + +func testObserverRequestGenerateKey(ctx context.Context, require *require.Assertions, nodes []*Node) { + node := nodes[0] + count, err := node.store.CountKeys(ctx) + require.Nil(err) + require.Equal(0, count) + testFROSTPrepareKeys(ctx, require, nodes, testFROSTKeys1, "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b") + + extra := []byte{1} + id := uuid.Must(uuid.NewV4()).String() + var sessionId string + for _, node := range nodes { + count, err = node.store.CountKeys(ctx) + require.Nil(err) + require.Equal(1, count) + key, err := node.store.ReadLatestPublicKey(ctx) + require.Nil(err) + require.Equal("fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b", key) + + out := testBuildObserverRequest(node, id, OperationTypeKeygenInput, extra) + sessionId = out.OutputId + testStep(ctx, require, node, out) + sessions, err := node.store.ListPreparedSessions(ctx, 500) + require.Nil(err) + require.Len(sessions, 1) + } + + members := node.GetMembers() + threshold := node.conf.MTG.Genesis.Threshold + sessionId = common.UniqueId(sessionId, fmt.Sprintf("OperationTypeKeygenInput:%d", 1)) + sessionId = common.UniqueId(sessionId, fmt.Sprintf("MTG:%v:%d", members, threshold)) + for _, node := range nodes { + testWaitOperation(ctx, node, sessionId) + } + time.Sleep(15 * time.Second) + for _, node := range nodes { + sessions, err := node.store.ListPreparedSessions(ctx, 500) + require.Nil(err) + require.Len(sessions, 0) + sessions, err = node.store.ListPendingSessions(ctx, 500) + require.Nil(err) + require.Len(sessions, 0) + count, err := node.store.CountKeys(ctx) + require.Nil(err) + require.Equal(2, count) + } + + testFROSTPrepareKeys(ctx, require, nodes, testFROSTKeys2, "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295") + count, err = node.store.CountKeys(ctx) + require.Nil(err) + require.Equal(3, count) + key, err := node.store.ReadLatestPublicKey(ctx) + require.Nil(err) + require.Equal("4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295", key) +} + +func testStorageSystemCall(ctx context.Context, nodes []*Node, extra []byte) []crypto.Hash { + raw := base64.RawURLEncoding.EncodeToString(extra) + ref := crypto.Sha256Hash(extra) + refs := []crypto.Hash{ref} + + for _, node := range nodes { + err := node.store.WriteProperty(ctx, ref.String(), raw) + if err != nil { + panic(err) + } + } + return refs +} + +func testObserverRequestSignSystemCall(ctx context.Context, require *require.Assertions, nodes []*Node, cid string) { + for _, node := range nodes { + testWaitOperation(ctx, node, cid) + } + for _, node := range nodes { + call, err := node.store.ReadSystemCallByRequestId(ctx, cid, common.RequestStatePending) + require.Nil(err) + require.True(call.Signature.Valid) + } +} + +func testBuildUserRequest(node *Node, id, hash, amt, asset string, action byte, extra []byte, references []crypto.Hash, fee *decimal.Decimal) *mtg.Action { + sequence += 10 + if hash == "" { + hash = crypto.Sha256Hash([]byte(id)).String() + } + + memo := []byte{action} + memo = append(memo, extra...) + memoStr := testEncodeMixinExtra(node.conf.AppId, memo) + memoStr = hex.EncodeToString([]byte(memoStr)) + timestamp := time.Now().UTC() + + amount := decimal.RequireFromString(amt) + if fee != nil { + amount = amount.Add(*fee) + } + + writeOutputReferences(id, references) + return &mtg.Action{ + UnifiedOutput: mtg.UnifiedOutput{ + OutputId: id, + TransactionHash: hash, + AppId: node.conf.AppId, + Senders: []string{string(node.id)}, + AssetId: asset, + Extra: memoStr, + Amount: amount, + SequencerCreatedAt: timestamp, + Sequence: sequence, + }, + } +} + +func testBuildObserverRequest(node *Node, id string, action byte, extra []byte) *mtg.Action { + sequence += 10 + memo := []byte{action} + memo = append(memo, extra...) + signed := node.signObserverExtra(memo) + memoStr := mtg.EncodeMixinExtraBase64(node.conf.AppId, signed) + memoStr = hex.EncodeToString([]byte(memoStr)) + timestamp := time.Now().UTC() + + return &mtg.Action{ + UnifiedOutput: mtg.UnifiedOutput{ + OutputId: id, + TransactionHash: crypto.Sha256Hash([]byte(id)).String(), + AppId: node.conf.AppId, + Senders: []string{string(node.id)}, + AssetId: bot.XINAssetId, + Extra: memoStr, + Amount: decimal.New(1, 1), + SequencerCreatedAt: timestamp, + Sequence: sequence, + }, + } +} + +func testStep(ctx context.Context, require *require.Assertions, node *Node, out *mtg.Action) { + txs1, asset := node.ProcessOutput(ctx, out) + require.Equal("", asset) + timestamp, err := node.timestamp(ctx) + require.Nil(err) + require.Equal(out.Sequence, timestamp) + req, err := node.store.TestReadPendingRequest(ctx) + require.Nil(err) + require.Nil(req) + req, err = node.store.ReadLatestRequest(ctx) + require.Nil(err) + ar, handled, err := node.store.ReadActionResult(ctx, out.OutputId, req.Id) + require.Nil(err) + require.True(handled) + require.Equal("", ar.Compaction) + txs2 := ar.Transactions + txs3, asset := node.ProcessOutput(ctx, out) + require.Equal("", asset) + for i, tx1 := range txs1 { + tx2 := txs2[i] + tx3 := txs3[i] + tx1.AppId = out.AppId + tx2.AppId = out.AppId + tx3.AppId = out.AppId + tx1.Sequence = out.Sequence + tx2.Sequence = out.Sequence + tx3.Sequence = out.Sequence + id := common.UniqueId(tx1.OpponentAppId, "test") + tx1.OpponentAppId = id + tx2.OpponentAppId = id + tx3.OpponentAppId = id + require.True(tx1.Equal(tx2)) + require.True(tx2.Equal(tx3)) + } +} + +func testPrepare(require *require.Assertions) (context.Context, []*Node, []*mtg.SQLite3Store) { + logger.SetLevel(logger.INFO) + ctx := context.Background() + ctx = common.EnableTestEnvironment(ctx) + + nodes := make([]*Node, 4) + mds := make([]*mtg.SQLite3Store, 4) + for i := range 4 { + dir := fmt.Sprintf("safe-signer-test-%d", i) + root, err := os.MkdirTemp("", dir) + require.Nil(err) + nodes[i], mds[i] = testBuildNode(ctx, require, root, i) + } + testInitOutputs(ctx, require, nodes, mds) + + network := newTestNetwork(nodes[0].GetPartySlice()) + for i := range 4 { + nodes[i].network = network + ctx = context.WithValue(ctx, partyContextKey, string(nodes[i].id)) + go network.mtgLoop(ctx, nodes[i]) + go nodes[i].loopInitialSessions(ctx) + go nodes[i].loopPreparedSessions(ctx) + go nodes[i].loopPendingSessions(ctx) + go nodes[i].acceptIncomingMessages(ctx) + } + + return ctx, nodes, mds +} + +func testBuildNode(ctx context.Context, require *require.Assertions, root string, i int) (*Node, *mtg.SQLite3Store) { + f, _ := os.ReadFile("../config/example.toml") + var conf struct { + Computer *Configuration `toml:"computer"` + } + err := toml.Unmarshal(f, &conf) + require.Nil(err) + + conf.Computer.StoreDir = root + conf.Computer.MTG.App.AppId = conf.Computer.MTG.Genesis.Members[i] + conf.Computer.MTG.GroupSize = 1 + conf.Computer.SolanaDepositEntry = "4jGVQSJrCfgLNSvTfwTLejm88bUXppqwvBzFZADtsY2F" + conf.Computer.MPCKeyNumber = 3 + + seed := crypto.Sha256Hash([]byte("computer-test")) + key := crypto.NewKeyFromSeed(append(seed[:], seed[:]...)) + conf.Computer.MTG.App.SpendPrivateKey = key.String() + conf.Computer.ObserverPublicKey = key.Public().String() + + if rpc := os.Getenv("SOLANARPC"); rpc != "" { + conf.Computer.SolanaRPC = rpc + } + + if !(strings.HasPrefix(conf.Computer.StoreDir, "/tmp/") || strings.HasPrefix(conf.Computer.StoreDir, "/var/folders")) { + panic(root) + } + kd, err := store.OpenSQLite3Store(conf.Computer.StoreDir + "/mpc.sqlite3") + require.Nil(err) + + md, err := mtg.OpenSQLite3Store(conf.Computer.StoreDir + "/mtg.sqlite3") + require.Nil(err) + group, err := mtg.BuildGroup(ctx, md, conf.Computer.MTG) + require.Nil(err) + group.EnableDebug() + + node := NewNode(kd, group, nil, conf.Computer, nil) + group.AttachWorker(node.conf.AppId, node) + return node, md +} + +func testInitOutputs(ctx context.Context, require *require.Assertions, nodes []*Node, mds []*mtg.SQLite3Store) { + start := sequence - 1 + conf := nodes[0].conf + for i := range 100 { + _, err := testWriteOutputForNodes(ctx, mds, conf.AppId, conf.AssetId, "", "", uint64(sequence), decimal.NewFromInt(1)) + require.Nil(err) + sequence += uint64(i + 1) + } + for i := range 100 { + _, err := testWriteOutputForNodes(ctx, mds, conf.AppId, mtg.StorageAssetId, "", "", uint64(sequence), decimal.NewFromInt(1)) + require.Nil(err) + sequence += uint64(i + 1) + } + for i := range 100 { + _, err := testWriteOutputForNodes(ctx, mds, conf.AppId, common.SafeSolanaChainId, "", "", uint64(sequence), decimal.NewFromInt(1)) + require.Nil(err) + sequence += uint64(i + 1) + } + for _, node := range nodes { + os := node.group.ListOutputsForAsset(ctx, conf.AppId, conf.AssetId, start, sequence, mtg.SafeUtxoStateUnspent, 500) + require.Len(os, 100) + os = node.group.ListOutputsForAsset(ctx, conf.AppId, mtg.StorageAssetId, start, sequence, mtg.SafeUtxoStateUnspent, 500) + require.Len(os, 100) + os = node.group.ListOutputsForAsset(ctx, conf.AppId, common.SafeSolanaChainId, start, sequence, mtg.SafeUtxoStateUnspent, 500) + require.Len(os, 100) + } +} + +func testWriteOutputForNodes(ctx context.Context, dbs []*mtg.SQLite3Store, appId, assetId, hash, extra string, sequence uint64, amount decimal.Decimal) (*mtg.UnifiedOutput, error) { + id := uuid.Must(uuid.NewV4()) + if hash == "" { + hash = crypto.Sha256Hash(id.Bytes()).String() + } + output := &mtg.UnifiedOutput{ + OutputId: id.String(), + AppId: appId, + AssetId: assetId, + Amount: amount, + Sequence: sequence, + SequencerCreatedAt: time.Now().UTC(), + TransactionHash: hash, + State: mtg.SafeUtxoStateUnspent, + Extra: extra, + } + for _, db := range dbs { + err := db.WriteAction(ctx, output, mtg.ActionStateDone) + if err != nil { + return nil, err + } + } + return output, nil +} + +func testFROSTPrepareKeys(ctx context.Context, require *require.Assertions, nodes []*Node, testKeys map[party.ID]string, public string) { + for _, node := range nodes { + parts := strings.Split(testKeys[node.id], ";") + pub, share := parts[0], parts[1] + conf, _ := hex.DecodeString(share) + require.Equal(public, pub) + id := common.UniqueId("prepare", public) + err := node.store.TestWriteKey(ctx, id, pub, conf, false) + require.Nil(err) + } +} + +func testGenerateRandNonceAccount(require *require.Assertions) [2]string { + key1, err := solana.NewRandomPrivateKey() + require.Nil(err) + key2, err := solana.NewRandomPrivateKey() + require.Nil(err) + return [2]string{key1.PublicKey().String(), solana.HashFromBytes(key2.PublicKey().Bytes()).String()} +} + +func testEncodeMixinExtra(appId string, extra []byte) string { + gid, err := uuid.FromString(appId) + if err != nil { + panic(err) + } + data := gid.Bytes() + data = append(data, extra...) + s := base64.RawURLEncoding.EncodeToString(data) + return s +} diff --git a/computer/frost.go b/computer/frost.go new file mode 100644 index 00000000..89019965 --- /dev/null +++ b/computer/frost.go @@ -0,0 +1,98 @@ +package computer + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/multi-party-sig/pkg/math/curve" + "github.com/MixinNetwork/multi-party-sig/pkg/party" + "github.com/MixinNetwork/multi-party-sig/protocols/frost" + "github.com/MixinNetwork/multi-party-sig/protocols/frost/keygen" + "github.com/MixinNetwork/multi-party-sig/protocols/frost/sign" + "github.com/MixinNetwork/safe/apps/mixin" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" +) + +const ( + frostKeygenRoundTimeout = 5 * time.Minute + frostSignRoundTimeout = 5 * time.Minute +) + +func (node *Node) frostKeygen(ctx context.Context, sessionId []byte, group curve.Curve) (*store.KeygenResult, error) { + logger.Printf("node.frostKeygen(%x)", sessionId) + start, err := frost.Keygen(group, node.id, node.GetPartySlice(), node.threshold)(sessionId) + if err != nil { + return nil, fmt.Errorf("frost.Keygen(%x) => %v", sessionId, err) + } + + keygenResult, err := node.handlerLoop(ctx, start, sessionId, frostKeygenRoundTimeout) + if err != nil { + return nil, fmt.Errorf("node.handlerLoop(%x) => %v", sessionId, err) + } + keygenConfig := keygenResult.(*frost.Config) + + return &store.KeygenResult{ + Public: common.MarshalPanic(keygenConfig.PublicPoint()), + Share: common.MarshalPanic(keygenConfig), + SSID: start.SSID(), + }, nil +} + +func (node *Node) frostSign(ctx context.Context, members []party.ID, public string, share []byte, m []byte, sessionId []byte, group curve.Curve, path []byte) (*store.SignResult, error) { + logger.Printf("node.frostSign(%x, %s, %x, %v)", sessionId, public, m, members) + conf := frost.EmptyConfig(group) + err := conf.UnmarshalBinary(share) + if err != nil { + panic(err) + } + P := conf.PublicPoint() + pb := common.MarshalPanic(P) + if hex.EncodeToString(pb) != public { + panic(public) + } + + if mixin.CheckEd25519ValidChildPath(path) { + conf = deriveEd25519Child(conf, pb, path) + P = conf.PublicPoint() + } + + start, err := frost.Sign(conf, members, m, sign.ProtocolEd25519SHA512)(sessionId) + if err != nil { + return nil, fmt.Errorf("frost.Sign(%x, %x) => %v", sessionId, m, err) + } + + signResult, err := node.handlerLoop(ctx, start, sessionId, frostSignRoundTimeout) + if err != nil { + return nil, fmt.Errorf("node.handlerLoop(%x) => %v", sessionId, err) + } + signature := signResult.(*frost.Signature) + logger.Printf("node.frostSign(%x, %s, %x) => %v", sessionId, public, m, signature) + if !signature.VerifyEd25519(P, m) { + return nil, fmt.Errorf("node.frostSign(%x, %s, %x) => %v verify", sessionId, public, m, signature) + } + + return &store.SignResult{ + Signature: signature.Serialize(), + SSID: start.SSID(), + }, nil +} + +func deriveEd25519Child(conf *keygen.Config, pb, path []byte) *keygen.Config { + adjust := conf.Curve().NewScalar() + seed := crypto.Sha256Hash(append(pb, path...)) + priv := crypto.NewKeyFromSeed(append(seed[:], seed[:]...)) + err := adjust.UnmarshalBinary(priv[:]) + if err != nil { + panic(err) + } + cc, err := conf.Derive(adjust, nil) + if err != nil { + panic(err) + } + return cc +} diff --git a/computer/group.go b/computer/group.go new file mode 100644 index 00000000..a4afdea9 --- /dev/null +++ b/computer/group.go @@ -0,0 +1,214 @@ +package computer + +import ( + "context" + "encoding/binary" + "math/big" + "time" + + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" +) + +const ( + KernelTimeout = 3 * time.Minute +) + +func (node *Node) ProcessOutput(ctx context.Context, out *mtg.Action) ([]*mtg.Transaction, string) { + logger.Verbosef("node.ProcessOutput(%v)", out) + if out.SequencerCreatedAt.IsZero() { + panic(out.OutputId) + } + txs1, asset1 := node.processAction(ctx, out) + txs2, asset2 := node.processAction(ctx, out) + mtg.ReplayCheck(out, txs1, txs2, asset1, asset2) + return txs1, asset1 +} + +func (node *Node) processAction(ctx context.Context, out *mtg.Action) ([]*mtg.Transaction, string) { + if common.CheckTestEnvironment(ctx) { + out.TestAttachActionToGroup(node.group) + } + if out.Sequence < node.conf.MTG.Genesis.Epoch && !common.CheckTestEnvironment(ctx) { + return nil, "" + } + + isDeposit := node.verifyKernelTransaction(ctx, out) + if isDeposit { + return node.processDeposit(ctx, out) + } + + req, err := node.parseRequest(out) + logger.Printf("node.parseRequest(%v) => %v %v", out, req, err) + if err != nil { + return nil, "" + } + + ar, handled, err := node.store.ReadActionResult(ctx, out.OutputId, req.Id) + logger.Printf("store.ReadActionResult(%s %s) => %v %t %v", out.OutputId, req.Id, ar, handled, err) + if err != nil { + panic(err) + } + if ar != nil { + return ar.Transactions, ar.Compaction + } + if handled { + err = node.store.FailAction(ctx, req) + if err != nil { + panic(err) + } + return nil, "" + } + + role := node.getActionRole(req.Action) + if role == 0 || role != req.Role { + logger.Printf("invalid role: %d %d", role, req.Role) + return nil, "" + } + err = req.VerifyFormat() + if err != nil { + logger.Printf("invalid format: %v", err) + panic(err) + } + err = node.store.WriteRequestIfNotExist(ctx, req) + if err != nil { + logger.Printf("WriteRequestIfNotExist() => %v", err) + panic(err) + } + + txs, asset := node.processRequest(ctx, req) + logger.Printf("node.processRequest(%v) => %v %s", req, txs, asset) + return txs, asset +} + +func (node *Node) getActionRole(act byte) byte { + switch act { + case OperationTypeAddUser: + return RequestRoleUser + case OperationTypeSystemCall: + return RequestRoleUser + case OperationTypeUserDeposit: + return RequestRoleUser + case OperationTypeSetOperationParams: + return RequestRoleObserver + case OperationTypeKeygenInput: + return RequestRoleObserver + case OperationTypeDeployExternalAssets: + return RequestRoleObserver + case OperationTypeConfirmNonce: + return RequestRoleObserver + case OperationTypeConfirmCall: + return RequestRoleObserver + case OperationTypeSignInput: + return RequestRoleObserver + case OperationTypeDeposit: + return RequestRoleObserver + case OperationTypeKeygenOutput: + return RequestRoleSigner + case OperationTypeSignPrepare: + return RequestRoleSigner + case OperationTypeSignOutput: + return RequestRoleSigner + default: + return 0 + } +} + +func (node *Node) processRequest(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + switch req.Action { + case OperationTypeKeygenInput, OperationTypeKeygenOutput: + default: + count, err := node.store.CountKeys(ctx) + if err != nil { + panic(err) + } + if count == 0 { + logger.Printf("processRequest(%v) => store.CountKeys() => %d", req, count) + return node.failRequest(ctx, req, "") + } + } + + switch req.Action { + case OperationTypeAddUser: + return node.processAddUser(ctx, req) + case OperationTypeSystemCall: + return node.processSystemCall(ctx, req) + case OperationTypeUserDeposit: + return node.processUserDeposit(ctx, req) + case OperationTypeSetOperationParams: + return node.processSetOperationParams(ctx, req) + case OperationTypeKeygenInput: + return node.processSignerKeygenRequests(ctx, req) + case OperationTypeDeployExternalAssets: + return node.processDeployExternalAssetsCall(ctx, req) + case OperationTypeConfirmNonce: + return node.processConfirmNonce(ctx, req) + case OperationTypeConfirmCall: + return node.processConfirmCall(ctx, req) + case OperationTypeSignInput: + return node.processObserverRequestSign(ctx, req) + case OperationTypeDeposit: + return node.processObserverCreateDepositCall(ctx, req) + case OperationTypeKeygenOutput: + return node.processSignerKeygenResults(ctx, req) + case OperationTypeSignPrepare: + return node.processSignerPrepare(ctx, req) + case OperationTypeSignOutput: + return node.processSignerSignatureResponse(ctx, req) + default: + panic(req.Action) + } +} + +func (node *Node) processSetOperationParams(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeSetOperationParams { + panic(req.Action) + } + + extra := req.ExtraBytes() + if len(extra) != 24 { + return node.failRequest(ctx, req, "") + } + + assetId := uuid.Must(uuid.FromBytes(extra[:16])) + abu := new(big.Int).SetUint64(binary.BigEndian.Uint64(extra[16:24])) + amount := decimal.NewFromBigInt(abu, -8) + params := &store.OperationParams{ + RequestId: req.Id, + OperationPriceAsset: assetId.String(), + OperationPriceAmount: amount, + CreatedAt: req.CreatedAt, + } + err := node.store.WriteOperationParamsFromRequest(ctx, params, req) + if err != nil { + panic(err) + } + return nil, "" +} + +func (node *Node) timestamp(ctx context.Context) (uint64, error) { + req, err := node.store.ReadLatestRequest(ctx) + if err != nil || req == nil { + return node.conf.MTG.Genesis.Epoch, err + } + return req.Sequence, nil +} + +func (node *Node) verifyKernelTransaction(ctx context.Context, out *mtg.Action) bool { + if common.CheckTestEnvironment(ctx) { + return false + } + + ver, err := common.VerifyKernelTransaction(ctx, node.group, out, KernelTimeout) + if err != nil { + panic(err) + } + return ver.DepositData() != nil +} diff --git a/computer/http.go b/computer/http.go new file mode 100644 index 00000000..9cdcf057 --- /dev/null +++ b/computer/http.go @@ -0,0 +1,296 @@ +package computer + +// FIXME do rate limit based on IP + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/dimfeld/httptreemux/v5" + "github.com/shopspring/decimal" +) + +//go:embed assets/mark.png +var FOOTMARK []byte + +//go:embed assets/favicon.ico +var FAVICON []byte +var VERSION string + +func (node *Node) StartHTTP(version string) { + VERSION = version + + router := httptreemux.New() + router.PanicHandler = common.HandlePanic + router.NotFoundHandler = common.HandleNotFound + + router.GET("/", node.httpIndex) + router.GET("/favicon.ico", node.httpFavicon) + router.GET("/users/:addr", node.httpGetUser) + router.GET("/deployed_assets", node.httpGetAssets) + router.GET("/system_calls/:id", node.httpGetSystemCall) + router.POST("/deployed_assets", node.httpDeployAssets) + router.POST("/nonce_accounts", node.httpLockNonce) + router.POST("/fee", node.httpGetFeeOnXIN) + handler := common.HandleCORS(router) + err := http.ListenAndServe(fmt.Sprintf(":%d", 7081), handler) + if err != nil { + panic(err) + } +} + +func (node *Node) httpIndex(w http.ResponseWriter, r *http.Request, params map[string]string) { + plan, err := node.store.ReadLatestOperationParams(r.Context(), time.Now()) + if err != nil { + common.RenderError(w, r, err) + return + } + height, err := node.readSolanaBlockCheckpoint(r.Context()) + if err != nil { + common.RenderError(w, r, err) + return + } + + common.RenderJSON(w, r, http.StatusOK, map[string]any{ + "version": VERSION, + "observer": node.conf.ObserverId, + "payer": node.SolanaPayer().String(), + "members": map[string]any{ + "app_id": node.conf.AppId, + "members": node.GetMembers(), + "threshold": node.conf.MTG.Genesis.Threshold, + }, + "params": map[string]any{ + "operation": map[string]any{ + "asset": plan.OperationPriceAsset, + "price": plan.OperationPriceAmount.String(), + }, + }, + "height": height, + }) +} + +func (node *Node) httpFavicon(w http.ResponseWriter, _ *http.Request, _ map[string]string) { + w.Header().Set("Content-Type", "image/vnd.microsoft.icon") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(FAVICON) +} + +func (node *Node) httpGetUser(w http.ResponseWriter, r *http.Request, params map[string]string) { + ctx := r.Context() + user, err := node.store.ReadUserByMixAddress(ctx, params["addr"]) + if err != nil { + common.RenderError(w, r, err) + return + } + if user == nil { + common.RenderJSON(w, r, http.StatusNotFound, map[string]any{"error": "user"}) + return + } + + common.RenderJSON(w, r, http.StatusOK, map[string]any{ + "id": user.UserId, + "mix_address": user.MixAddress, + "chain_address": user.ChainAddress, + }) +} + +func (node *Node) httpGetSystemCall(w http.ResponseWriter, r *http.Request, params map[string]string) { + ctx := r.Context() + call, err := node.store.ReadSystemCallByRequestId(ctx, params["id"], 0) + if err != nil { + common.RenderError(w, r, err) + return + } + if call == nil || call.Type != store.CallTypeMain { + common.RenderJSON(w, r, http.StatusNotFound, map[string]any{"error": "404"}) + return + } + var state string + switch call.State { + case common.RequestStateInitial: + state = "initial" + case common.RequestStatePending: + state = "pending" + case common.RequestStateDone: + state = "done" + case common.RequestStateFailed: + state = "failed" + } + + resp := map[string]any{ + "id": call.RequestId, + "user_id": call.UserIdFromPublicPath(), + "nonce_account": call.NonceAccount, + "raw": call.Raw, + "state": state, + "hash": call.Hash.String, + } + if call.State == common.RequestStateFailed { + reason, err := node.store.ReadFailReason(ctx, call.RequestId) + if err != nil { + common.RenderError(w, r, err) + return + } + resp["reason"] = reason + } + + common.RenderJSON(w, r, http.StatusOK, resp) +} + +func (node *Node) httpGetAssets(w http.ResponseWriter, r *http.Request, params map[string]string) { + ctx := r.Context() + as, err := node.store.ListDeployedAssets(ctx) + if err != nil { + common.RenderError(w, r, err) + return + } + um, err := node.store.ListAssetIconUrls(ctx) + if err != nil { + common.RenderError(w, r, err) + return + } + + view := make([]map[string]any, 0) + for _, asset := range as { + view = append(view, map[string]any{ + "asset_id": asset.AssetId, + "address": asset.Address, + "decimals": asset.Decimals, + "uri": um[asset.AssetId], + }) + } + common.RenderJSON(w, r, http.StatusOK, view) +} + +func (node *Node) httpDeployAssets(w http.ResponseWriter, r *http.Request, params map[string]string) { + ctx := r.Context() + var body struct { + Assets []string `json:"assets"` + } + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": err}) + return + } + + var assets []*store.ExternalAsset + now := time.Now().UTC() + for _, id := range body.Assets { + old, err := node.store.ReadExternalAsset(ctx, id) + if err != nil { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": err}) + } + if old != nil { + assets = append(assets, old) + continue + } + asset, err := common.SafeReadAssetUntilSufficient(ctx, id) + if err != nil { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": err}) + return + } + if asset.ChainID == common.SafeSolanaChainId { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": "chain"}) + return + } + assets = append(assets, &store.ExternalAsset{ + AssetId: id, + CreatedAt: now, + }) + } + err = node.store.WriteExternalAssets(ctx, assets) + if err != nil { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": err}) + return + } + + common.RenderJSON(w, r, http.StatusOK, map[string]any{ + "assets": assets, + }) +} + +func (node *Node) httpLockNonce(w http.ResponseWriter, r *http.Request, params map[string]string) { + ctx := r.Context() + var body struct { + Mix string `json:"mix"` + } + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": err}) + return + } + + user, err := node.store.ReadUserByMixAddress(ctx, body.Mix) + if err != nil { + common.RenderError(w, r, err) + return + } + if user == nil { + common.RenderJSON(w, r, http.StatusNotFound, map[string]any{"error": "user"}) + return + } + nonce, err := node.store.ReadSpareNonceAccount(ctx) + if err != nil { + common.RenderError(w, r, err) + return + } + if nonce == nil { + common.RenderJSON(w, r, http.StatusNotFound, map[string]any{"error": "nonce"}) + return + } + hash, err := node.solana.GetNonceAccountHash(ctx, nonce.Account().Address) + if err != nil { + common.RenderError(w, r, err) + return + } + if hash.String() != nonce.Hash { + panic(fmt.Errorf("inconsistent nonce hash: %s %s %s", nonce.Address, nonce.Hash, hash.String())) + } + + err = node.store.LockNonceAccountWithMix(ctx, nonce.Address, body.Mix) + if err != nil { + common.RenderError(w, r, err) + return + } + + common.RenderJSON(w, r, http.StatusOK, map[string]any{ + "mix": body.Mix, + "nonce_address": nonce.Address, + "nonce_hash": nonce.Hash, + }) +} + +func (node *Node) httpGetFeeOnXIN(w http.ResponseWriter, r *http.Request, params map[string]string) { + ctx := r.Context() + var body struct { + SolAmount decimal.Decimal `json:"sol_amount"` + } + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil { + common.RenderJSON(w, r, http.StatusBadRequest, map[string]any{"error": err}) + return + } + + fee, err := node.store.ReadLatestFeeInfo(ctx) + if err != nil { + common.RenderError(w, r, err) + return + } + if fee == nil { + common.RenderJSON(w, r, http.StatusNotFound, map[string]any{"error": "fee"}) + return + } + ratio := decimal.RequireFromString(fee.Ratio) + xinAmount := body.SolAmount.Div(ratio).RoundCeil(8) + + common.RenderJSON(w, r, http.StatusOK, map[string]any{ + "fee_id": fee.Id, + "xin_amount": xinAmount, + }) +} diff --git a/computer/icon.go b/computer/icon.go new file mode 100644 index 00000000..f1219f31 --- /dev/null +++ b/computer/icon.go @@ -0,0 +1,141 @@ +package computer + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "io" + "net/http" + + "github.com/MixinNetwork/bot-api-go-client/v3" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/chai2010/webp" + "github.com/disintegration/imaging" + "github.com/fogleman/gg" +) + +const size = 512 + +func readImageFromUrl(url string) (*image.NRGBA, error) { + if url[len(url)-5:] == "=s128" { + url = url[:len(url)-5] + "=s512" + } + res, err := http.Get(url) + if err != nil || res.StatusCode != 200 { + return nil, fmt.Errorf("http.Get(%s) => %v, %v", url, res, err) + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + icon512 := imaging.Resize(img, size, size, imaging.Lanczos) + return icon512, nil +} + +func applyCircleMask(img image.Image) image.Image { + dc := gg.NewContext(size, size) + dc.DrawRoundedRectangle(0, 0, size, size, size/2) + dc.Clip() + dc.DrawImage(img, 0, 0) + return dc.Image() +} + +func getWebpBase64(img image.Image) (string, error) { + var buf bytes.Buffer + err := webp.Encode(&buf, img, &webp.Options{ + Lossless: true, + Exact: true, + }) + if err != nil { + return "", err + } + base64Str := base64.StdEncoding.EncodeToString(buf.Bytes()) + return "data:image/webp;base64," + base64Str, nil +} + +func (node *Node) processAssetIcon(ctx context.Context, asset *bot.AssetNetwork) (string, error) { + icon, err := readImageFromUrl(asset.IconURL) + if err != nil { + return "", err + } + if asset.AssetID != bot.XINAssetId { + mark, _, err := image.Decode(bytes.NewReader(FOOTMARK)) + if err != nil { + return "", err + } + icon = imaging.Overlay(icon, mark, image.Pt(0, 0), 1.0) + } + + circle := applyCircleMask(icon) + imgBase64, err := getWebpBase64(circle) + if err != nil { + return "", err + } + data, err := json.Marshal(map[string]any{ + "content": imgBase64, + }) + if err != nil { + return "", err + } + + traceId := common.UniqueId(node.group.GenesisId(), asset.AssetID) + traceId = common.UniqueId(traceId, node.SafeUser().SpendPrivateKey) + traceId = common.UniqueId(traceId, "icon") + hash, err := common.WriteStorageUntilSufficient(ctx, node.mixin, nil, data, traceId, *node.SafeUser()) + if err != nil { + return "", err + } + iconUrl := fmt.Sprintf("https://kernel.mixin.dev/objects/%s/content", hash.String()) + err = node.store.UpdateExternalAssetIconUrl(ctx, asset.AssetID, iconUrl) + if err != nil { + return "", err + } + return iconUrl, nil +} + +func (node *Node) checkExternalAssetUri(ctx context.Context, asset *bot.AssetNetwork) (string, error) { + ea, err := node.store.ReadExternalAsset(ctx, asset.AssetID) + if err != nil || ea == nil { + return "", fmt.Errorf("invalid external asset to mint: %s", asset.AssetID) + } + if ea.Uri.Valid { + return ea.Uri.String, nil + } + iconUrl, err := node.processAssetIcon(ctx, asset) + if err != nil { + return "", err + } + meta := solanaApp.Metadata{ + Name: asset.Name, + Symbol: asset.Symbol, + Description: fmt.Sprintf("%s bridged through Mixin Computer", asset.Name), + Image: iconUrl, + } + data, err := json.Marshal(meta) + if err != nil { + return "", err + } + traceId := common.UniqueId(node.group.GenesisId(), asset.AssetID) + traceId = common.UniqueId(traceId, node.SafeUser().SpendPrivateKey) + traceId = common.UniqueId(traceId, "metadata") + hash, err := common.WriteStorageUntilSufficient(ctx, node.mixin, nil, data, traceId, *node.SafeUser()) + if err != nil { + return "", err + } + url := fmt.Sprintf("https://kernel.mixin.dev/objects/%s", hash.String()) + err = node.store.UpdateExternalAssetUri(ctx, ea.AssetId, url) + if err != nil { + return "", err + } + return url, nil +} diff --git a/computer/interface.go b/computer/interface.go new file mode 100644 index 00000000..25056970 --- /dev/null +++ b/computer/interface.go @@ -0,0 +1,50 @@ +package computer + +import ( + "context" + + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/messenger" + "github.com/MixinNetwork/safe/mtg" +) + +type Configuration struct { + AppId string `toml:"app-id"` + StoreDir string `toml:"store-dir"` + MessengerConversationId string `toml:"messenger-conversation-id"` + MonitorConversationId string `toml:"monitor-conversation-id"` + Timestamp int64 `toml:"timestamp"` + Threshold int `toml:"threshold"` + AssetId string `toml:"asset-id"` + ObserverId string `toml:"observer-id"` + ObserverPublicKey string `toml:"observer-spend-public-key"` + OperationPriceAssetId string `toml:"operation-price-asset-id"` + OperationPriceAmount string `toml:"operation-price-amount"` + MPCKeyNumber int `toml:"mpc-key-number"` + MixinMessengerAPI string `toml:"mixin-messenger-api"` + MixinRPC string `toml:"mixin-rpc"` + SolanaRPC string `toml:"solana-rpc"` + SolanaKey string `toml:"solana-key"` + SolanaDepositEntry string `toml:"solana-deposit-entry"` + MTG *mtg.Configuration `toml:"mtg"` +} + +func (c *Configuration) Messenger() *messenger.MixinConfiguration { + return &messenger.MixinConfiguration{ + UserId: c.MTG.App.AppId, + SessionId: c.MTG.App.SessionId, + Key: c.MTG.App.SessionPrivateKey, + ConversationId: c.MessengerConversationId, + ReceiveBuffer: 128, + SendBuffer: 64, + } +} + +type Network interface { + ReceiveMessage(context.Context) (*messenger.MixinMessage, error) + QueueMessage(ctx context.Context, receiver string, b []byte) error +} + +func OpenSQLite3Store(path string) (*store.SQLite3Store, error) { + return store.OpenSQLite3Store(path) +} diff --git a/computer/mvm.go b/computer/mvm.go new file mode 100644 index 00000000..ab31bdf2 --- /dev/null +++ b/computer/mvm.go @@ -0,0 +1,905 @@ +package computer + +import ( + "context" + "database/sql" + "encoding/hex" + "fmt" + "math/big" + "slices" + "strings" + "time" + + "github.com/MixinNetwork/bot-api-go-client/v3" + mc "github.com/MixinNetwork/mixin/common" + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/mixin/util/base58" + "github.com/MixinNetwork/safe/apps/mixin" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/gagliardetto/solana-go" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" +) + +const ( + ConfirmFlagNonceAvailable = 0 + ConfirmFlagNonceExpired = 1 + + FlagWithPostProcess = 0 + FlagSkipPostProcess = 1 +) + +func (node *Node) processAddUser(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleUser { + panic(req.Role) + } + if req.Action != OperationTypeAddUser { + panic(req.Action) + } + + plan, err := node.store.ReadLatestOperationParams(ctx, req.CreatedAt) + if err != nil { + panic(err) + } + if plan == nil || + !plan.OperationPriceAmount.IsPositive() || + req.AssetId != plan.OperationPriceAsset || + req.Amount.Cmp(plan.OperationPriceAmount) < 0 { + return node.failRequest(ctx, req, "") + } + + mix := string(req.ExtraBytes()) + _, err = bot.NewMixAddressFromString(mix) + logger.Printf("common.NewAddressFromString(%s) => %v", mix, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + + old, err := node.store.ReadUserByMixAddress(ctx, mix) + logger.Printf("store.ReadUserByAddress(%s) => %v %v", mix, old, err) + if err != nil { + panic(fmt.Errorf("store.ReadUserByAddress(%s) => %v", mix, err)) + } else if old != nil { + return node.failRequest(ctx, req, "") + } + + id, err := node.store.GetNextUserId(ctx) + logger.Printf("store.GetNextUserId() => %s %v", id.String(), err) + if err != nil { + panic(err) + } + master, err := node.store.ReadLatestPublicKey(ctx) + logger.Printf("store.ReadLatestPublicKey() => %s %v", master, err) + if err != nil || master == "" { + panic(fmt.Errorf("store.ReadLatestPublicKey() => %s %v", master, err)) + } + public := mixin.DeriveEd25519Child(master, id.FillBytes(make([]byte, 8))) + chainAddress := solana.PublicKeyFromBytes(public[:]).String() + + err = node.store.WriteUserWithRequest(ctx, req, id.String(), mix, chainAddress, master) + if err != nil { + panic(fmt.Errorf("store.WriteUserWithRequest(%v %s) => %v", req, mix, err)) + } + return nil, "" +} + +func (node *Node) processUserDeposit(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleUser { + panic(req.Role) + } + if req.Action != OperationTypeUserDeposit { + panic(req.Action) + } + + data := req.ExtraBytes() + if len(data) != 8 { + logger.Printf("invalid extra length of request for user deposit: %d", len(data)) + return node.failRequest(ctx, req, "") + } + id := new(big.Int).SetBytes(data[:8]) + user, err := node.store.ReadUser(ctx, id.String()) + logger.Printf("store.ReadUser(%d) => %v %v", id, user, err) + if err != nil { + panic(fmt.Errorf("store.ReadUser() => %v", err)) + } else if user == nil { + return node.failRequest(ctx, req, "") + } + + asset, err := common.SafeReadAssetUntilSufficient(ctx, req.AssetId) + if err != nil || asset == nil { + panic(err) + } + + output := &store.UserOutput{ + OutputId: req.Output.OutputId, + UserId: user.UserId, + TransactionHash: req.Output.TransactionHash, + OutputIndex: req.Output.OutputIndex, + AssetId: req.AssetId, + ChainId: asset.ChainID, + Amount: req.Amount.String(), + State: common.RequestStateInitial, + CreatedAt: req.CreatedAt, + UpdatedAt: req.CreatedAt, + } + err = node.store.WriteUserDepositWithRequest(ctx, req, output) + if err != nil { + panic(err) + } + + return nil, "" +} + +// System call operation full lifecycle: +// +// 1. user creates system call with locked nonce +// memo: user id (8 bytes) | call id (16 bytes) | skip post-process flag (1 byte) | fee id (16 bytes if needed) +// if memo includes the fee id and mtg receives extra amount of XIN (> 0.001), same value of SOL would be tranfered to user account in prepare system call. +// processSystemCall +// (state: initial, withdrawal_traces: NULL, signature: NULL) +// +// 2. observer confirms nonce available and creates prepare system call to transfer assets to user account in advance +// mvm creates withdrawal txs and makes sign requests for user system call and prepare system call +// processConfirmNonce +// (user system call, state: pending, withdrawal_traces: NOT NULL, signature: NULL) +// (prepare system call, state: pending, withdrawal_traces: "", signature: NULL) +// +// 1). observer requests to regenerate signatures for system calls if timeout +// processObserverRequestSign +// +// 2). mtg generate signatures for system calls +// processSignerSignatureResponse +// (user system call, signature: NOT NULL) +// (prepare system call, signature: NOT NULL) +// +// 3. observer pays the withdrawal fees +// +// 4. observer runs prepare system call and user system call in a row if withdrawals of user system call are all confirmed, +// builds post-process system call to transfer solana assets to mtg deposit entry and burn external assets if needed, +// then confirms the two calls successful in one request to mtg with the post-process call. +// mtg would mark the prepare and user system call as done, and makes sign requests for post-process system call +// processConfirmCall +// (prepare system call, state: done, hash: NOT NULL) +// (user system call, state: done, hash: NOT NULL) +// (post-process system call, state: pending, signature: NULL) +// +// 1). mtg generate signatures for post-process system call +// processSignerSignatureResponse +// (post-process system call, signature: NOT NULL) +// +// 5. observer runs, confirms post-process call successfully +// (post-process system call, state: done) +func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleUser { + panic(req.Role) + } + if req.Action != OperationTypeSystemCall { + panic(req.Action) + } + + data := req.ExtraBytes() + if len(data) != 25 && len(data) != 41 { // because a fee id for observer usage + logger.Printf("invalid extra length of request to create system call: %d", len(data)) + return node.failRequest(ctx, req, "") + } + id := new(big.Int).SetBytes(data[:8]) + user, err := node.store.ReadUser(ctx, id.String()) + logger.Printf("store.ReadUser(%d) => %v %v", id, user, err) + if err != nil { + panic(fmt.Errorf("store.ReadUser() => %v", err)) + } else if user == nil { + return node.failRequest(ctx, req, "") + } + mix, err := bot.NewMixAddressFromString(user.MixAddress) + if err != nil { + panic(err) + } + if !slices.ContainsFunc(mix.Members(), func(m string) bool { + return slices.Contains(req.Output.Senders, m) + }) && !common.CheckTestEnvironment(ctx) { + // TODO use better and general authentication without MM api + return node.failRequest(ctx, req, "") + } + + os, storage, err := node.GetSystemCallReferenceOutputs(ctx, user.UserId, req.MixinHash.String(), common.RequestStateInitial) + logger.Printf("node.GetSystemCallReferenceTxs(%s) => %v %v %v", req.MixinHash.String(), os, storage, err) + if err != nil || storage == nil { + return node.failRequest(ctx, req, "") + } + + cid := uuid.Must(uuid.FromBytes(data[8:24])).String() + skipPostProcess := false + switch data[24] { + case FlagSkipPostProcess: + skipPostProcess = true + case FlagWithPostProcess: + default: + logger.Printf("invalid skip post process flag: %d", data[24]) + return node.failRequest(ctx, req, "") + } + + plan, err := node.store.ReadLatestOperationParams(ctx, req.CreatedAt) + if err != nil { + panic(err) + } + if plan == nil || + !plan.OperationPriceAmount.IsPositive() || + req.AssetId != plan.OperationPriceAsset || + req.Amount.Cmp(plan.OperationPriceAmount) < 0 { + return node.failRequest(ctx, req, "") + } + + rb := node.readStorageExtraFromObserver(ctx, *storage) + call, tx, err := node.buildSystemCallFromBytes(ctx, req, cid, rb, false) + if err != nil { + return node.failRequest(ctx, req, "") + } + call.Superior = call.RequestId + call.Type = store.CallTypeMain + call.Public = hex.EncodeToString(user.FingerprintWithPath()) + call.SkipPostProcess = skipPostProcess + + err = node.checkUserSystemCall(ctx, tx) + if err != nil { + logger.Printf("node.checkUserSystemCall(%v) => %v", tx, err) + return node.failRequest(ctx, req, "") + } + + err = node.store.WriteInitialSystemCallWithRequest(ctx, req, call, os) + logger.Printf("solana.WriteInitialSystemCallWithRequest(%v %d) => %v", call, len(os), err) + if err != nil { + panic(err) + } + + return nil, "" +} + +func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeConfirmNonce { + panic(req.Action) + } + + extra := req.ExtraBytes() + flag, extra := extra[0], extra[1:] + callId := uuid.Must(uuid.FromBytes(extra[0:16])).String() + + call, err := node.store.ReadSystemCallByRequestId(ctx, callId, common.RequestStateInitial) + logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", callId, call, err) + if err != nil { + panic(err) + } + if call == nil || call.WithdrawalTraces.Valid { + return node.failRequest(ctx, req, "") + } + user, err := node.store.ReadUser(ctx, call.UserIdFromPublicPath()) + if err != nil || user == nil { + panic(fmt.Errorf("store.ReadUser(%s) => %v %v", call.UserIdFromPublicPath(), user, err)) + } + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + logger.Printf("node.GetSystemCallReferenceTxs(%s) => %v %v", req.MixinHash.String(), os, err) + if err != nil { + panic(err) + } + as := node.GetSystemCallRelatedAsset(ctx, os) + + switch flag { + case ConfirmFlagNonceAvailable: + var sessions []*store.Session + prepare, tx, err := node.getSubSystemCallFromExtra(ctx, req, extra[16:]) + if err != nil { + return node.failRequest(ctx, req, "") + } + if prepare != nil { + prepare.Superior = call.RequestId + prepare.Type = store.CallTypePrepare + prepare.Public = hex.EncodeToString(user.FingerprintWithEmptyPath()) + prepare.State = common.RequestStatePending + + err = node.VerifySubSystemCall(ctx, tx, solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry), solana.MustPublicKeyFromBase58(user.ChainAddress)) + logger.Printf("node.VerifySubSystemCall(%s) => %v", user.ChainAddress, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + err = node.comparePrepareCallWithSolanaTx(tx, as) + logger.Printf("node.comparePrepareCallWithSolanaTx(%s) => %v", call.RequestId, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + + sessions = append(sessions, &store.Session{ + Id: prepare.RequestId, + RequestId: prepare.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeSignInput, + Public: prepare.Public, + Extra: prepare.MessageHex(), + CreatedAt: req.CreatedAt, + }) + + index, err := solanaApp.GetSignatureIndexOfAccount(*tx, node.getMTGAddress(ctx)) + if err != nil { + panic(err) + } + if index == -1 { + prepare.Signature = sql.NullString{Valid: true, String: ""} + } + } + + var txs []*mtg.Transaction + var ids []string + destination := node.getMTGAddress(ctx).String() + for _, asset := range as { + if !asset.Solana { + continue + } + id := common.UniqueId(req.Id, asset.AssetId) + id = common.UniqueId(id, "withdrawal") + memo := []byte(call.RequestId) + tx := node.buildWithdrawalTransaction(ctx, req.Output, asset.AssetId, asset.Amount.String(), memo, destination, "", id) + if tx == nil { + return node.failRequest(ctx, req, asset.AssetId) + } + txs = append(txs, tx) + ids = append(ids, tx.TraceId) + } + call.RequestSignerAt = sql.NullTime{Valid: true, Time: req.CreatedAt} + call.WithdrawalTraces = sql.NullString{Valid: true, String: strings.Join(ids, ",")} + call.State = common.RequestStatePending + + sessions = append(sessions, &store.Session{ + Id: call.RequestId, + RequestId: call.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 1, + Operation: OperationTypeSignInput, + Public: call.Public, + Extra: call.MessageHex(), + CreatedAt: req.CreatedAt, + }) + + err = node.store.ConfirmNonceAvailableWithRequest(ctx, req, call, prepare, sessions, txs, "") + if err != nil { + panic(err) + } + return txs, "" + case ConfirmFlagNonceExpired: + mix, err := bot.NewMixAddressFromString(user.MixAddress) + if err != nil { + panic(err) + } + return node.refundAndFailRequest(ctx, req, mix.Members(), int(mix.Threshold), call, os) + default: + logger.Printf("invalid nonce confirm flag: %d", flag) + return node.failRequest(ctx, req, "") + } +} + +func (node *Node) processDeployExternalAssetsCall(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeDeployExternalAssets { + panic(req.Action) + } + + var as []*solanaApp.DeployedAsset + extra := req.ExtraBytes() + n, extra := extra[0], extra[1:] + offset := 0 + for len(as) < int(n) { + assetId := uuid.Must(uuid.FromBytes(extra[offset : offset+16])).String() + offset += 16 + address := solana.PublicKeyFromBytes(extra[offset : offset+32]).String() + offset += 32 + + asset, err := common.SafeReadAssetUntilSufficient(ctx, assetId) + if err != nil { + panic(err) + } + if asset == nil || asset.ChainID == solanaApp.SolanaChainBase { + logger.Printf("processDeployExternalAssets(%s) => invalid asset", assetId) + return node.failRequest(ctx, req, "") + } + old, err := node.store.ReadDeployedAsset(ctx, assetId) + if err != nil { + panic(err) + } + if old != nil { + logger.Printf("processDeployExternalAssets(%s) => asset already existed", assetId) + return node.failRequest(ctx, req, "") + } + if !common.CheckTestEnvironment(ctx) { // TODO should not skip the test + mint, err := node.RPCGetAsset(ctx, address) + if err != nil || mint == nil || + mint.Decimals != uint32(asset.Precision) || + mint.MintAuthority != node.getMTGAddress(ctx).String() || + mint.FreezeAuthority != "" { + // TODO check symbol and name + panic(fmt.Errorf("solana.RPCGetAsset(%s) => %v", address, mint)) + } + } + as = append(as, &solanaApp.DeployedAsset{ + AssetId: assetId, + ChainId: asset.ChainID, + Address: address, + Decimals: int64(asset.Precision), + Asset: asset, + }) + logger.Verbosef("processDeployExternalAssets() => %s %s", assetId, address) + } + + err := node.store.WriteDeployedAssetsWithRequest(ctx, req, as) + logger.Printf("store.WriteDeployedAssetsWithRequest() => %v", err) + if err != nil { + panic(err) + } + return nil, "" +} + +func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeConfirmCall { + panic(req.Action) + } + + extra := req.ExtraBytes() + flag, extra := extra[0], extra[1:] + + switch flag { + case FlagConfirmCallSuccess: + n, extra := int(extra[0]), extra[1:] + if n == 0 || n > 2 { + logger.Printf("invalid length of signature: %d", n) + return node.failRequest(ctx, req, "") + } + + var calls []*store.SystemCall + + signature := base58.Encode(extra[:64]) + call, tx, err := node.checkConfirmCallSignature(ctx, signature) + if err != nil { + logger.Printf("node.checkConfirmCallSignature(%s) => %v", signature, err) + return node.failRequest(ctx, req, "") + } + + switch call.Type { + case store.CallTypeDeposit: + err := node.store.ConfirmSystemCallsWithRequest(ctx, req, []*store.SystemCall{call}, nil, nil, nil) + if err != nil { + panic(err) + } + return nil, "" + case store.CallTypePostProcess: + return node.confirmPostProcessSystemCall(ctx, req, call, tx) + case store.CallTypePrepare: + calls = append(calls, call) + if n == 2 { + signature := base58.Encode(extra[64:128]) + call, _, err = node.checkConfirmCallSignature(ctx, signature) + if err != nil { + return node.failRequest(ctx, req, "") + } + calls = append(calls, call) + } + case store.CallTypeMain: + if n == 2 { + panic(call.Type) + } + calls = append(calls, call) + default: + panic(call.Type) + } + + var session *store.Session + var outputs []*store.UserOutput + var post *store.SystemCall + if call.Type == store.CallTypeMain { + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + if err != nil { + panic(err) + } + outputs = os + + post, err = node.getPostProcessCall(ctx, req, call, extra[n*64:]) + logger.Printf("node.getPostProcessCall(%v %v) => %v %v", req, call, post, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + if post != nil { + session = &store.Session{ + Id: post.RequestId, + RequestId: post.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeSignInput, + Public: post.Public, + Extra: post.MessageHex(), + CreatedAt: req.CreatedAt, + } + } + } + err = node.store.ConfirmSystemCallsWithRequest(ctx, req, calls, post, session, outputs) + if err != nil { + panic(err) + } + return nil, "" + case FlagConfirmCallFail: + callId := uuid.Must(uuid.FromBytes(extra[:16])).String() + call, err := node.store.ReadSystemCallByRequestId(ctx, callId, common.RequestStatePending) + logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", callId, call, err) + if err != nil { + panic(err) + } + if call == nil { + return node.failRequest(ctx, req, "") + } + + var outputs []*store.UserOutput + switch call.Type { + case store.CallTypeMain, store.CallTypePrepare: + main := call + if call.Type == store.CallTypePrepare { + c, err := node.store.ReadSystemCallByRequestId(ctx, call.Superior, common.RequestStatePending) + logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", call.Superior, call, err) + if err != nil || c == nil { + panic(err) + } + main = c + } + + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, common.RequestStatePending) + if err != nil { + panic(err) + } + outputs = os + } + + var session *store.Session + post, err := node.getPostProcessCall(ctx, req, call, extra[16:]) + logger.Printf("node.getPostProcessCall(%v %v) => %v %v", req, call, post, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + if post != nil { + session = &store.Session{ + Id: post.RequestId, + RequestId: post.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeSignInput, + Public: post.Public, + Extra: post.MessageHex(), + CreatedAt: req.CreatedAt, + } + } + + err = node.store.FailSystemCallWithRequest(ctx, req, call, post, session, outputs) + if err != nil { + panic(err) + } + return nil, "" + default: + logger.Printf("invalid confirm flag: %d", flag) + return node.failRequest(ctx, req, "") + } +} + +func (node *Node) processObserverRequestSign(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeSignInput { + panic(req.Action) + } + + extra := req.ExtraBytes() + callId := uuid.Must(uuid.FromBytes(extra[:16])).String() + call, err := node.store.ReadSystemCallByRequestId(ctx, callId, common.RequestStatePending) + logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", callId, call, err) + if err != nil { + panic(err) + } + if call == nil || call.Signature.Valid || call.State == common.RequestStateFailed { + return node.failRequest(ctx, req, "") + } + if call.RequestSignerAt.Valid && call.RequestSignerAt.Time.Add(20*time.Minute).After(req.CreatedAt) { + return node.failRequest(ctx, req, "") + } + + old, err := node.store.ReadSession(ctx, req.Id) + logger.Printf("store.ReadSession(%s) => %v %v", req.Id, old, err) + if err != nil { + panic(err) + } + if old != nil { + return node.failRequest(ctx, req, "") + } + + session := &store.Session{ + Id: req.Id, + RequestId: call.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeSignInput, + Public: call.Public, + Extra: call.MessageHex(), + CreatedAt: req.CreatedAt, + } + err = node.store.WriteSignSessionWithRequest(ctx, req, call, []*store.Session{session}) + if err != nil { + panic(err) + } + return nil, "" +} + +// create system call to transfer assets to mtg deposit entry from user account on Solana +func (node *Node) processObserverCreateDepositCall(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + logger.Printf("node.processObserverCreateDepositCall(%s)", string(node.id)) + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeDeposit { + panic(req.Action) + } + + extra := req.ExtraBytes() + userAddress := solana.PublicKeyFromBytes(extra[:32]) + signature := solana.SignatureFromBytes(extra[32:]) + + user, err := node.store.ReadUserByChainAddress(ctx, userAddress.String()) + logger.Printf("store.ReadUserByChainAddress(%s) => %v %v", userAddress.String(), user, err) + if err != nil { + panic(err) + } + if user == nil { + return node.failRequest(ctx, req, "") + } + + call, tx, err := node.getSubSystemCallFromExtra(ctx, req, extra[96:]) + if err != nil { + logger.Printf("node.getSubSystemCallFromExtra(%v) => %v", req, err) + return node.failRequest(ctx, req, "") + } + err = node.VerifySubSystemCall(ctx, tx, solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry), userAddress) + logger.Printf("node.VerifySubSystemCall(%s %s) => %v", node.conf.SolanaDepositEntry, userAddress, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + call.Superior = call.RequestId + call.Type = store.CallTypeDeposit + call.Public = hex.EncodeToString(user.FingerprintWithPath()) + call.State = common.RequestStatePending + + err = node.compareDepositCallWithSolanaTx(ctx, tx, signature.String(), user.ChainAddress) + if err != nil { + logger.Printf("node.compareDepositCallWithSolanaTx(%s %s) => %v", signature.String(), user.ChainAddress, err) + return node.failRequest(ctx, req, "") + } + + session := &store.Session{ + Id: call.RequestId, + RequestId: call.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeSignInput, + Public: call.Public, + Extra: call.MessageHex(), + CreatedAt: req.CreatedAt, + } + err = node.store.WriteDepositCallWithRequest(ctx, req, call, session) + if err != nil { + panic(err) + } + + return nil, "" +} + +// deposit from Solana to mtg deposit entry +func (node *Node) processDeposit(ctx context.Context, out *mtg.Action) ([]*mtg.Transaction, string) { + logger.Printf("node.processDeposit(%v)", out) + ar, handled, err := node.store.ReadActionResult(ctx, out.OutputId, out.OutputId) + logger.Printf("store.ReadActionResult(%s %s) => %v %t %v", out.OutputId, out.OutputId, ar, handled, err) + if err != nil { + panic(err) + } + if ar != nil { + return ar.Transactions, ar.Compaction + } + if handled { + err = node.store.FailAction(ctx, &store.Request{ + Id: out.OutputId, + Output: out, + }) + if err != nil { + panic(err) + } + return nil, "" + } + + ver, err := common.VerifyKernelTransaction(ctx, node.group, out, time.Minute) + if err != nil { + panic(err) + } + deposit := ver.DepositData() + + rpcTx, err := node.RPCGetTransaction(ctx, deposit.Transaction) + if err != nil { + panic(err) + } + tx, err := rpcTx.Transaction.GetTransaction() + if err != nil { + panic(err) + } + err = node.processTransactionWithAddressLookups(ctx, tx) + if err != nil { + panic(err) + } + ts, err := solanaApp.ExtractTransfersFromTransaction(ctx, tx, rpcTx.Meta, nil) + if err != nil { + panic(err) + } + + var txs []*mtg.Transaction + var compaction string + for i, t := range ts { + logger.Printf("%d-th transfer: %v", i, t) + if t.AssetId != out.AssetId { + continue + } + if t.Receiver != node.solanaDepositEntry().String() { + continue + } + user, err := node.store.ReadUserByChainAddress(ctx, t.Sender) + logger.Verbosef("store.ReadUserByAddress(%s) => %v %v", t.Sender, user, err) + if err != nil { + panic(err) + } else if user == nil { + continue + } + mix, err := bot.NewMixAddressFromString(user.MixAddress) + if err != nil { + panic(err) + } + asset, err := common.SafeReadAssetUntilSufficient(ctx, t.AssetId) + if err != nil { + panic(err) + } + expected := mc.NewIntegerFromString(decimal.NewFromBigInt(t.Value, -int32(asset.Precision)).String()) + actual := mc.NewIntegerFromString(out.Amount.String()) + if expected.Cmp(actual) != 0 { + panic(fmt.Errorf("invalid deposit amount: %s %s", expected.String(), actual.String())) + } + id := common.UniqueId(deposit.Transaction, fmt.Sprintf("deposit-%d", i)) + id = common.UniqueId(id, t.Receiver) + tx := node.buildTransaction(ctx, out, node.conf.AppId, t.AssetId, mix.Members(), int(mix.Threshold), out.Amount.String(), []byte("deposit"), id) + if tx == nil { + compaction = t.AssetId + txs = nil + break + } + txs = append(txs, tx) + } + + state := common.RequestStateDone + if compaction != "" { + state = common.RequestStateFailed + } + err = node.store.WriteDepositRequestIfNotExist(ctx, out, state, txs, compaction) + logger.Printf("store.WriteDepositRequestIfNotExist(%v %d %d %s) => %v", out, state, len(txs), compaction, err) + if err != nil { + panic(err) + } + + return txs, compaction +} + +func (node *Node) refundAndFailRequest(ctx context.Context, req *store.Request, members []string, threshod int, call *store.SystemCall, os []*store.UserOutput) ([]*mtg.Transaction, string) { + as := node.GetSystemCallRelatedAsset(ctx, os) + txs, compaction := node.buildRefundTxs(ctx, req, as, members, threshod) + err := node.store.RefundOutputsWithRequest(ctx, req, call, os, txs, compaction) + if err != nil { + panic(err) + } + return txs, compaction +} + +func (node *Node) checkConfirmCallSignature(ctx context.Context, signature string) (*store.SystemCall, *solana.Transaction, error) { + transaction, err := node.RPCGetTransaction(ctx, signature) + if err != nil || transaction == nil { + panic(fmt.Errorf("checkConfirmCallSignature(%s) => %v", signature, err)) + } + tx, err := transaction.Transaction.GetTransaction() + if err != nil { + panic(err) + } + msg, err := tx.Message.MarshalBinary() + if err != nil { + panic(err) + } + hash := crypto.Sha256Hash(msg).String() + + if common.CheckTestEnvironment(ctx) { + cs, err := node.store.ListSignedCalls(ctx) + if err != nil { + panic(err) + } + fmt.Println("===") + fmt.Println(signature) + fmt.Println(hex.EncodeToString(msg)) + for _, c := range cs { + fmt.Println(c.Type, c.MessageHash) + } + test := getTestSystemConfirmCallMessage(signature) + if test != "" { + hash = test + } + } + + call, err := node.store.ReadSystemCallByMessage(ctx, hash) + if err != nil { + panic(fmt.Errorf("store.ReadSystemCallByMessage(%x) => %v", msg, err)) + } + if call == nil || call.State != common.RequestStatePending { + return nil, nil, fmt.Errorf("checkConfirmCallSignature(%s) => invalid call %v", signature, call) + } + call.State = common.RequestStateDone + call.Hash = sql.NullString{Valid: true, String: signature} + return call, tx, nil +} + +func (node *Node) confirmPostProcessSystemCall(ctx context.Context, req *store.Request, call *store.SystemCall, tx *solana.Transaction) ([]*mtg.Transaction, string) { + user, err := node.store.ReadUser(ctx, call.UserIdFromPublicPath()) + if err != nil { + panic(err) + } + mix, err := bot.NewMixAddressFromString(user.MixAddress) + if err != nil { + panic(err) + } + + var txs []*mtg.Transaction + bs := solanaApp.ExtractBurnsFromTransaction(ctx, tx) + for _, burn := range bs { + address := burn.GetMintAccount().PublicKey.String() + da, err := node.store.ReadDeployedAssetByAddress(ctx, address) + if err != nil || da == nil { + panic(err) + } + + amount := decimal.New(int64(*burn.Amount), -int32(da.Decimals)).String() + amt := mc.NewIntegerFromString(amount) + if amt.Sign() == 0 { + continue + } + + id := common.UniqueId(call.RequestId, fmt.Sprintf("BURN:%s", da.AssetId)) + id = common.UniqueId(id, user.MixAddress) + tx := node.buildTransaction(ctx, req.Output, node.conf.AppId, da.AssetId, mix.Members(), int(mix.Threshold), amt.String(), nil, id) + if tx == nil { + return node.failRequest(ctx, req, da.AssetId) + } + txs = append(txs, tx) + } + + err = node.store.ConfirmPostProcessSystemCallWithRequest(ctx, req, call, txs) + if err != nil { + panic(err) + } + return txs, "" +} diff --git a/computer/node.go b/computer/node.go index 0ad4df95..6163be4a 100644 --- a/computer/node.go +++ b/computer/node.go @@ -1 +1,171 @@ package computer + +import ( + "context" + "fmt" + "slices" + "sort" + "strconv" + "sync" + "time" + + "github.com/MixinNetwork/bot-api-go-client/v3" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/multi-party-sig/pkg/party" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/fox-one/mixin-sdk-go/v2" +) + +type Node struct { + id party.ID + threshold int + + conf *Configuration + group *mtg.Group + network Network + mutex *sync.Mutex + sessions map[string]*MultiPartySession + operations map[string]bool + store *store.SQLite3Store + + solana *solanaApp.Client + mixin *mixin.Client +} + +func NewNode(store *store.SQLite3Store, group *mtg.Group, network Network, conf *Configuration, mixin *mixin.Client) *Node { + node := &Node{ + id: party.ID(conf.MTG.App.AppId), + threshold: conf.Threshold, + conf: conf, + group: group, + network: network, + mutex: new(sync.Mutex), + sessions: make(map[string]*MultiPartySession), + operations: make(map[string]bool), + store: store, + mixin: mixin, + solana: solanaApp.NewClient(conf.SolanaRPC), + } + + members := node.GetMembers() + mgt := conf.MTG.Genesis.Threshold + if mgt < conf.Threshold || mgt < len(members)*2/3+1 { + panic(fmt.Errorf("%d/%d/%d", conf.Threshold, mgt, len(members))) + } + + return node +} + +func (node *Node) Boot(ctx context.Context, version string) { + go node.bootObserver(ctx, version) + go node.bootSigner(ctx) + + mtg := node.getMTGAddress(ctx) + logger.Printf("node.Boot(%s, %d, %s)", node.id, node.Index(), mtg.String()) +} + +func (node *Node) Index() int { + index := node.findMember(string(node.id)) + if index < 0 { + panic(node.id) + } + return index +} + +func (node *Node) findMember(m string) int { + return slices.Index(node.GetMembers(), m) +} + +func (node *Node) synced(ctx context.Context) bool { + if common.CheckTestEnvironment(ctx) { + return true + } + // TODO all nodes send group timestamp to others, and not synced + // if one of them has a big difference + return node.group.Synced(ctx) +} + +func (node *Node) GetMembers() []string { + ms := make([]string, len(node.conf.MTG.Genesis.Members)) + copy(ms, node.conf.MTG.Genesis.Members) + sort.Strings(ms) + return ms +} + +func (node *Node) IsMember(id string) bool { + return slices.Contains(node.GetMembers(), id) +} + +func (node *Node) IsFromGroup(senders []string) bool { + members := node.GetMembers() + if len(members) != len(senders) { + return false + } + sort.Strings(senders) + return slices.Equal(members, senders) +} + +func (node *Node) GetPartySlice() party.IDSlice { + members := node.GetMembers() + ms := make(party.IDSlice, len(members)) + for i, id := range members { + ms[i] = party.ID(id) + } + return ms +} + +func (node *Node) SafeUser() *bot.SafeUser { + return &bot.SafeUser{ + UserId: node.conf.MTG.App.AppId, + SessionId: node.conf.MTG.App.SessionId, + ServerPublicKey: node.conf.MTG.App.ServerPublicKey, + SessionPrivateKey: node.conf.MTG.App.SessionPrivateKey, + SpendPrivateKey: node.conf.MTG.App.SpendPrivateKey, + } +} + +func (node *Node) readRequestNumber(ctx context.Context, key string) (int64, error) { + val, err := node.store.ReadProperty(ctx, key) + if err != nil || val == "" { + return 0, err + } + num, err := strconv.ParseInt(val, 10, 64) + if err != nil { + panic(err) + } + return num, nil +} + +func (node *Node) writeRequestNumber(ctx context.Context, key string, sequence int64) error { + return node.store.WriteProperty(ctx, key, fmt.Sprintf("%d", sequence)) +} + +func (node *Node) readSolanaBlockCheckpoint(ctx context.Context) (int64, error) { + height, err := node.readRequestNumber(ctx, store.SolanaScanHeightKey) + if err != nil || height == 0 { + return 315360000, err + } + return height, nil +} + +func (node *Node) readPropertyAsTime(ctx context.Context, key string) time.Time { + val, err := node.store.ReadProperty(ctx, key) + if err != nil { + panic(err) + } + if val == "" { + return time.Unix(0, node.conf.Timestamp) + } + ts, err := time.Parse(time.RFC3339Nano, val) + if err != nil { + panic(val) + } + return ts +} + +func (node *Node) writeRequestTime(ctx context.Context, key string, offset time.Time) error { + return node.store.WriteProperty(ctx, key, offset.Format(time.RFC3339Nano)) +} diff --git a/computer/observer.go b/computer/observer.go new file mode 100644 index 00000000..f45f9b03 --- /dev/null +++ b/computer/observer.go @@ -0,0 +1,872 @@ +package computer + +import ( + "context" + "encoding/base64" + "encoding/binary" + "fmt" + "strings" + "sync" + "time" + + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" +) + +const ( + loopInterval = time.Second * 5 + BalanceLimit = 500000000 +) + +func (node *Node) bootObserver(ctx context.Context, version string) { + if string(node.id) != node.conf.ObserverId { + return + } + logger.Printf("bootObserver(%s)", node.id) + go node.StartHTTP(version) + + err := node.initMPCKeys(ctx) + if err != nil { + panic(err) + } + err = node.sendPriceInfo(ctx) + if err != nil { + panic(err) + } + + err = node.checkNonceAccounts(ctx) + if err != nil { + panic(err) + } + + go node.initializeUsersLoop(ctx) + go node.deployOrConfirmAssetsLoop(ctx) + + go node.createNonceAccountLoop(ctx) + go node.releaseNonceAccountLoop(ctx) + + go node.feeInfoLoop(ctx) + go node.withdrawalFeeLoop(ctx) + go node.unconfirmedWithdrawalLoop(ctx) + + go node.unconfirmedCallLoop(ctx) + go node.unsignedCallLoop(ctx) + go node.signedCallLoop(ctx) + + go node.solanaRPCBlocksLoop(ctx) +} + +func (node *Node) initMPCKeys(ctx context.Context) error { + for { + count, err := node.store.CountKeys(ctx) + if err != nil || count >= node.conf.MPCKeyNumber { + return err + } + + requestAt := node.readPropertyAsTime(ctx, store.KeygenRequestTimeKey) + if time.Since(requestAt) < time.Hour { + time.Sleep(1 * time.Minute) + continue + } + + now := time.Now().UTC() + for i := count; i < node.conf.MPCKeyNumber; i++ { + id := common.UniqueId(node.group.GenesisId(), fmt.Sprintf("MPC:BASE:%d", i)) + id = common.UniqueId(id, now.String()) + extra := []byte{byte(i)} + err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeKeygenInput, + Extra: extra, + }, nil) + if err != nil { + return err + } + } + + err = node.writeRequestTime(ctx, store.KeygenRequestTimeKey, now) + if err != nil { + return err + } + } +} + +func (node *Node) sendPriceInfo(ctx context.Context) error { + amount := decimal.RequireFromString(node.conf.OperationPriceAmount) + logger.Printf("node.sendPriceInfo(%s, %s)", node.conf.OperationPriceAssetId, amount) + amount = amount.Mul(decimal.New(1, 8)) + if amount.Sign() <= 0 || !amount.IsInteger() || !amount.BigInt().IsInt64() { + panic(node.conf.OperationPriceAmount) + } + id := common.UniqueId("OperationTypeSetOperationParams", node.conf.OperationPriceAssetId) + id = common.UniqueId(id, amount.String()) + extra := uuid.Must(uuid.FromString(node.conf.OperationPriceAssetId)).Bytes() + extra = binary.BigEndian.AppendUint64(extra, uint64(amount.IntPart())) + return node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeSetOperationParams, + Extra: extra, + }, nil) +} + +func (node *Node) checkNonceAccounts(ctx context.Context) error { + calls, err := node.store.CountUserSystemCallByState(ctx, common.RequestStateInitial) + if err != nil { + panic(err) + } + if calls > 0 { + return nil + } + calls, err = node.store.CountUserSystemCallByState(ctx, common.RequestStatePending) + if err != nil { + panic(err) + } + if calls > 0 { + return nil + } + ns, err := node.store.ListLockedNonceAccounts(ctx) + if err != nil { + panic(err) + } + if len(ns) > 0 { + return nil + } + + ns, err = node.store.ListNonceAccounts(ctx) + if err != nil { + panic(err) + } + for _, nonce := range ns { + hash, err := node.solana.GetNonceAccountHash(ctx, nonce.Account().Address) + if err != nil { + panic(err) + } + if hash.String() == nonce.Hash { + continue + } + logger.Printf("solana.checkNonceAccount(%s) => %s %s", nonce.Account().Address, nonce.Account().Hash, hash.String()) + err = node.store.UpdateNonceAccount(ctx, nonce.Address, hash.String(), "") + if err != nil { + panic(err) + } + } + return nil +} + +func (node *Node) initializeUsersLoop(ctx context.Context) { + for { + err := node.initializeUsers(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) deployOrConfirmAssetsLoop(ctx context.Context) { + for { + err := node.deployOrConfirmAssets(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) createNonceAccountLoop(ctx context.Context) { + for { + err := node.createNonceAccounts(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) releaseNonceAccountLoop(ctx context.Context) { + for { + err := node.releaseNonceAccounts(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) feeInfoLoop(ctx context.Context) { + for { + err := node.handleFeeInfo(ctx) + if err != nil { + panic(err) + } + + time.Sleep(7 * time.Minute) + } +} + +func (node *Node) withdrawalFeeLoop(ctx context.Context) { + for { + err := node.handleWithdrawalsFee(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) unconfirmedWithdrawalLoop(ctx context.Context) { + for { + err := node.handleUnconfirmedWithdrawals(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) unconfirmedCallLoop(ctx context.Context) { + for { + err := node.handleUnconfirmedCalls(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) unsignedCallLoop(ctx context.Context) { + for { + err := node.processUnsignedCalls(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) signedCallLoop(ctx context.Context) { + for { + err := node.handleSignedCalls(ctx) + if err != nil { + panic(err) + } + + time.Sleep(loopInterval) + } +} + +func (node *Node) initializeUsers(ctx context.Context) error { + offset := node.readPropertyAsTime(ctx, store.UserInitializeTimeKey) + us, err := node.store.ListNewUsersAfter(ctx, offset) + if err != nil || len(us) == 0 { + return err + } + + for _, u := range us { + err := node.InitializeAccount(ctx, u) + if err != nil { + return err + } + err = node.writeRequestTime(ctx, store.UserInitializeTimeKey, u.CreatedAt) + if err != nil { + return err + } + time.Sleep(loopInterval) + } + return nil +} + +func (node *Node) deployOrConfirmAssets(ctx context.Context) error { + es, err := node.store.ListUndeployedAssets(ctx) + if err != nil || len(es) == 0 { + return err + } + + var as []string + for _, a := range es { + old, err := node.store.ReadDeployedAsset(ctx, a.AssetId) + if err != nil { + return err + } + if old != nil { + continue + } + as = append(as, a.AssetId) + } + if len(as) == 0 { + return nil + } + + id, tx, assets, err := node.CreateMintsTransaction(ctx, as) + if err != nil || tx == nil { + return err + } + payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey) + _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) + if err != nil { + panic(err) + } + rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, nil) + if err != nil { + return err + } + tx, err = rpcTx.Transaction.GetTransaction() + if err != nil { + return err + } + + extra := []byte{byte(len(assets))} + for _, asset := range assets { + extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) + extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) + } + err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeDeployExternalAssets, + Extra: extra, + }, nil) + if err != nil { + return err + } + + return node.store.MarkExternalAssetDeployed(ctx, assets, tx.Signatures[0].String()) +} + +func (node *Node) createNonceAccounts(ctx context.Context) error { + count, err := node.store.CountNonceAccounts(ctx) + if err != nil || count > 100 { + return err + } + requested := node.readPropertyAsTime(ctx, store.NonceAccountRequestTimeKey) + if requested.Add(10 * time.Second).After(time.Now().UTC()) { + return nil + } + address, hash, err := node.CreateNonceAccount(ctx, count) + if err != nil { + return fmt.Errorf("node.CreateNonceAccount() => %v", err) + } + err = node.store.WriteNonceAccount(ctx, address, hash) + if err != nil { + return fmt.Errorf("store.WriteNonceAccount(%s %s) => %v", address, hash, err) + } + return node.writeRequestTime(ctx, store.NonceAccountRequestTimeKey, time.Now().UTC()) +} + +func (node *Node) releaseNonceAccounts(ctx context.Context) error { + as, err := node.store.ListLockedNonceAccounts(ctx) + if err != nil { + return err + } + for _, nonce := range as { + if nonce.LockedByUserOnly() && nonce.Expired() { + node.releaseLockedNonceAccount(ctx, nonce) + continue + } + + call, err := node.store.ReadSystemCallByRequestId(ctx, nonce.CallId.String, 0) + if err != nil { + return err + } + if call == nil { + if nonce.Expired() { + node.releaseLockedNonceAccount(ctx, nonce) + } + continue + } + if nonce.Address != call.NonceAccount { + node.releaseLockedNonceAccount(ctx, nonce) + continue + } + switch call.State { + case common.RequestStateFailed: + node.releaseLockedNonceAccount(ctx, nonce) + case common.RequestStateDone: + if nonce.UpdatedBy.Valid && nonce.UpdatedBy.String == call.RequestId { + node.releaseLockedNonceAccount(ctx, nonce) + return nil + } + for { + newNonceHash, err := node.solana.GetNonceAccountHash(ctx, nonce.Account().Address) + if err != nil { + panic(err) + } + if newNonceHash.String() == nonce.Hash { + time.Sleep(3 * time.Second) + continue + } + err = node.store.UpdateNonceAccount(ctx, nonce.Address, newNonceHash.String(), call.RequestId) + if err != nil { + panic(err) + } + break + } + } + } + return nil +} + +func (node *Node) releaseLockedNonceAccount(ctx context.Context, nonce *store.NonceAccount) { + logger.Printf("observer.releaseLockedNonceAccount(%v)", nonce) + hash, err := node.solana.GetNonceAccountHash(ctx, nonce.Account().Address) + if err != nil { + panic(err) + } + if hash.String() != nonce.Hash { + panic(fmt.Errorf("observer.releaseLockedNonceAccount(%s) => inconsistent hash %s %s ", + nonce.Address, nonce.Hash, hash.String())) + } + err = node.store.ReleaseLockedNonceAccount(ctx, nonce.Address) + if err != nil { + panic(err) + } +} + +func (node *Node) handleFeeInfo(ctx context.Context) error { + xin, err := common.SafeReadAssetUntilSufficient(ctx, common.XINKernelAssetId) + if err != nil { + return err + } + sol, err := common.SafeReadAssetUntilSufficient(ctx, common.SafeSolanaChainId) + if err != nil { + return err + } + xinPrice := decimal.RequireFromString(xin.PriceUSD) + solPrice := decimal.RequireFromString(sol.PriceUSD) + ratio := xinPrice.Div(solPrice) + + id := uuid.Must(uuid.NewV4()).String() + return node.store.WriteFeeInfo(ctx, id, ratio) +} + +func (node *Node) handleWithdrawalsFee(ctx context.Context) error { + txs := node.group.ListUnconfirmedWithdrawalTransactions(ctx, 500) + for _, tx := range txs { + if !tx.Destination.Valid { + panic(tx.TraceId) + } + asset, err := common.SafeReadAssetUntilSufficient(ctx, tx.AssetId) + if err != nil { + return err + } + if asset.ChainID != common.SafeSolanaChainId { + continue + } + fee, err := common.SafeReadWithdrawalFeeUntilSufficient(ctx, node.SafeUser(), asset.AssetID, common.SafeSolanaChainId, tx.Destination.String) + if err != nil { + return err + } + if fee.AssetID != common.SafeSolanaChainId { + panic(fee.AssetID) + } + rid := common.UniqueId(tx.TraceId, "withdrawal_fee") + amount := decimal.RequireFromString(fee.Amount) + refs := []crypto.Hash{tx.Hash} + _, err = common.SendTransactionUntilSufficient(ctx, node.mixin, []string{node.conf.MTG.App.AppId}, 1, []string{mtg.MixinFeeUserId}, 1, amount, rid, fee.AssetID, "", refs, node.conf.MTG.App.SpendPrivateKey) + if err != nil { + return err + } + } + return nil +} + +func (node *Node) handleUnconfirmedWithdrawals(ctx context.Context) error { + start := node.readPropertyAsTime(ctx, store.WithdrawalConfirmRequestTimeKey) + txs := node.group.ListConfirmedWithdrawalTransactionsAfter(ctx, start, 100) + for _, tx := range txs { + if !tx.WithdrawalHash.Valid { + return fmt.Errorf("invalid withdrawal hash: %s", tx.TraceId) + } + cid := uuid.Must(uuid.FromString(tx.Memo)).String() + call, err := node.store.ReadSystemCallByRequestId(ctx, cid, common.RequestStatePending) + if err != nil || call == nil { + return fmt.Errorf("store.ReadSystemCallByRequestId(%s %d) => %v %v", cid, common.RequestStatePending, call, err) + } + + err = node.store.WriteConfirmedWithdrawal(ctx, &store.ConfirmedWithdrawal{ + Hash: tx.WithdrawalHash.String, + TraceId: tx.TraceId, + CallId: call.RequestId, + CreatedAt: tx.UpdatedAt, + }) + if err != nil { + return err + } + } + return nil +} + +func (node *Node) handleUnconfirmedCalls(ctx context.Context) error { + calls, err := node.store.ListUnconfirmedSystemCalls(ctx) + if err != nil { + return err + } + for _, call := range calls { + logger.Printf("observer.handleUnconfirmedCall(%s)", call.RequestId) + nonce, err := node.store.ReadNonceAccount(ctx, call.NonceAccount) + if err != nil { + return err + } + + id := common.UniqueId(call.RequestId, "confirm-nonce") + extra := []byte{ConfirmFlagNonceAvailable} + extra = append(extra, uuid.Must(uuid.FromString(call.RequestId)).Bytes()...) + + if nonce == nil || !nonce.Valid(call.RequestId) { + logger.Printf("observer.expireSystemCall(%v %v %v)", call, nonce, err) + id = common.UniqueId(id, "expire-nonce") + extra[0] = ConfirmFlagNonceExpired + err = node.store.WriteFailedCallIfNotExist(ctx, call, "expired or invalid nonce") + if err != nil { + return err + } + } else { + cid := common.UniqueId(id, "storage") + nonce, err := node.store.ReadSpareNonceAccount(ctx) + if err != nil { + return err + } + err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) + if err != nil { + return err + } + fee, err := node.getSystemCallFeeFromXIN(ctx, call) + if err != nil { + return err + } + tx, err := node.CreatePrepareTransaction(ctx, call, nonce, fee) + if err != nil { + return err + } + if tx != nil { + tb, err := tx.MarshalBinary() + if err != nil { + panic(err) + } + extra = attachSystemCall(extra, cid, tb) + } + err = node.store.OccupyNonceAccountByCall(ctx, call.NonceAccount, call.RequestId) + if err != nil { + return err + } + } + + err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeConfirmNonce, + Extra: extra, + }, nil) + if err != nil { + return err + } + logger.Printf("observer.confirmNonce(%s %d %d)", call.RequestId, OperationTypeConfirmNonce, extra[0]) + } + return nil +} + +func (node *Node) processUnsignedCalls(ctx context.Context) error { + calls, err := node.store.ListUnsignedCalls(ctx) + if err != nil { + return err + } + for _, call := range calls { + now := time.Now().UTC() + if call.RequestSignerAt.Valid && call.RequestSignerAt.Time.Add(20*time.Minute).After(now) { + continue + } + logger.Printf("observer.processUnsignedCalls(%s %d)", call.RequestId, len(calls)) + offset := call.CreatedAt + if call.RequestSignerAt.Valid { + offset = call.RequestSignerAt.Time + } + id := common.UniqueId(call.RequestId, offset.String()) + extra := uuid.Must(uuid.FromString(call.RequestId)).Bytes() + err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeSignInput, + Extra: extra, + }, nil) + if err != nil { + return err + } + } + return nil +} + +func (node *Node) handleSignedCalls(ctx context.Context) error { + balance, err := node.solana.RPCGetBalance(ctx, node.SolanaPayer()) + if err != nil { + return err + } + if balance < BalanceLimit { + logger.Printf("insufficient balance to send tx: %d", balance) + time.Sleep(30 * time.Second) + return nil + } + + callMap, err := node.store.ListSignedCalls(ctx) + if err != nil { + return err + } + callSequence := make(map[string][]*store.SystemCall) + for _, call := range callMap { + switch call.Type { + case store.CallTypeDeposit, store.CallTypePostProcess: + key := fmt.Sprintf("%s:%s", call.Type, call.RequestId) + callSequence[key] = append(callSequence[key], call) + case store.CallTypeMain: + pending, err := node.store.CheckUnfinishedSubCalls(ctx, call) + if err != nil { + panic(err) + } + unconfirmed, err := node.store.CheckUnconfirmedWithdrawals(ctx, call) + if err != nil { + panic(err) + } + // should be processed with its prepare call together or wait withdrawals getting confirmed + if pending || unconfirmed { + continue + } + + key := fmt.Sprintf("%s:%s", store.CallTypeMain, call.UserIdFromPublicPath()) + // should be processed after previous main call being confirmed + if len(callSequence[key]) > 0 { + continue + } + callSequence[key] = append(callSequence[key], call) + case store.CallTypePrepare: + main := callMap[call.Superior] + if main == nil { + continue + } + unconfirmed, err := node.store.CheckUnconfirmedWithdrawals(ctx, main) + if err != nil { + panic(err) + } + // should wait withdrawals of its main call getting confirmed + if unconfirmed { + continue + } + key := fmt.Sprintf("%s:%s", store.CallTypeMain, main.UserIdFromPublicPath()) + // should be processed after previous main call being confirmed + if len(callSequence[key]) > 0 { + continue + } + callSequence[key] = append(callSequence[key], call) + callSequence[key] = append(callSequence[key], main) + } + } + + var wg sync.WaitGroup + for key, calls := range callSequence { + wg.Add(1) + go node.handleSignedCallSequence(ctx, &wg, key, calls) + } + wg.Wait() + return nil +} + +func (node *Node) handleSignedCallSequence(ctx context.Context, wg *sync.WaitGroup, key string, calls []*store.SystemCall) { + defer wg.Done() + var ids []string + for _, c := range calls { + ids = append(ids, c.RequestId) + } + logger.Printf("node.handleSignedCallSequence(%s) => %s", key, strings.Join(ids, ",")) + if len(calls) > 2 { + panic(fmt.Errorf("invalid call sequence length: %s", strings.Join(ids, ","))) + } + + if len(calls) == 1 { + call := calls[0] + + if call.Type == store.CallTypeMain { + pending, err := node.store.CheckUnfinishedSubCalls(ctx, call) + if err != nil { + panic(err) + } + unconfirmed, err := node.store.CheckUnconfirmedWithdrawals(ctx, call) + if err != nil { + panic(err) + } + // should be processed with its prepare call together or wait withdrawals getting confirmed + if pending || unconfirmed { + return + } + } + + tx, meta, err := node.handleSignedCall(ctx, call) + if err != nil { + err = node.processFailedCall(ctx, call, err) + if err != nil { + panic(err) + } + return + } + err = node.processSuccessedCall(ctx, call, tx, meta, []solana.Signature{tx.Signatures[0]}) + if err != nil { + panic(err) + } + return + } + + var sigs []solana.Signature + preTx, _, err := node.handleSignedCall(ctx, calls[0]) + if err != nil { + err = node.processFailedCall(ctx, calls[0], err) + if err != nil { + panic(err) + } + return + } + sigs = append(sigs, preTx.Signatures[0]) + + err = node.checkCreatedAtaUntilSufficient(ctx, preTx) + if err != nil { + panic(err) + } + + tx, meta, err := node.handleSignedCall(ctx, calls[1]) + if err != nil { + err = node.processFailedCall(ctx, calls[1], err) + if err != nil { + panic(err) + } + return + } + sigs = append(sigs, tx.Signatures[0]) + + err = node.processSuccessedCall(ctx, calls[1], tx, meta, sigs) + if err != nil { + panic(err) + } +} + +func (node *Node) handleSignedCall(ctx context.Context, call *store.SystemCall) (*solana.Transaction, *rpc.TransactionMeta, error) { + logger.Printf("node.handleSignedCall(%s)", call.RequestId) + payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey) + publicKey := node.getUserSolanaPublicKeyFromCall(ctx, call) + tx, err := solana.TransactionFromBase64(call.Raw) + if err != nil { + panic(err) + } + err = node.processTransactionWithAddressLookups(ctx, tx) + if err != nil { + panic(err) + } + _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) + if err != nil { + panic(err) + } + + index, err := solanaApp.GetSignatureIndexOfAccount(*tx, publicKey) + if err != nil { + panic(err) + } + if index >= 0 { + sig, err := base64.StdEncoding.DecodeString(call.Signature.String) + if err != nil { + panic(err) + } + tx.Signatures[index] = solana.SignatureFromBytes(sig) + } + + rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, call) + if err != nil || rpcTx == nil { + return nil, nil, fmt.Errorf("node.SendTransactionUtilConfirm(%s) => %v %v", call.RequestId, rpcTx, err) + } + txx, err := rpcTx.Transaction.GetTransaction() + if err != nil { + panic(err) + } + return txx, rpcTx.Meta, nil +} + +// deposited assets to run system call and new assets received in system call are all handled here +func (node *Node) processSuccessedCall(ctx context.Context, call *store.SystemCall, txx *solana.Transaction, meta *rpc.TransactionMeta, hashes []solana.Signature) error { + id := common.UniqueId(call.RequestId, "confirm-success") + extra := []byte{FlagConfirmCallSuccess} + extra = append(extra, byte(len(hashes))) + for _, hash := range hashes { + extra = append(extra, hash[:]...) + } + + if call.Type == store.CallTypeMain && !call.SkipPostProcess { + cid := common.UniqueId(id, "post-process") + nonce, err := node.store.ReadSpareNonceAccount(ctx) + if err != nil { + return err + } + tx := node.CreatePostProcessTransaction(ctx, call, nonce, txx, meta) + if tx != nil { + err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) + if err != nil { + return err + } + data, err := tx.MarshalBinary() + if err != nil { + panic(err) + } + extra = attachSystemCall(extra, cid, data) + } + } + + return node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeConfirmCall, + Extra: extra, + }, nil) +} + +func (node *Node) processFailedCall(ctx context.Context, call *store.SystemCall, callError error) error { + logger.Printf("node.processFailedCall(%s)", call.RequestId) + id := common.UniqueId(call.RequestId, "confirm-fail") + extra := []byte{FlagConfirmCallFail} + extra = append(extra, uuid.Must(uuid.FromString(call.RequestId)).Bytes()...) + + if call.Type == store.CallTypeMain { + cid := common.UniqueId(id, "post-process") + nonce, err := node.store.ReadSpareNonceAccount(ctx) + if err != nil { + panic(err) + } + tx := node.CreatePostProcessTransaction(ctx, call, nonce, nil, nil) + if tx != nil { + err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) + if err != nil { + return err + } + data, err := tx.MarshalBinary() + if err != nil { + panic(err) + } + extra = attachSystemCall(extra, cid, data) + } + } + + err := node.store.WriteFailedCallIfNotExist(ctx, call, callError.Error()) + if err != nil { + return err + } + + return node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeConfirmCall, + Extra: extra, + }, nil) +} diff --git a/computer/request.go b/computer/request.go new file mode 100644 index 00000000..fbcc6b48 --- /dev/null +++ b/computer/request.go @@ -0,0 +1,164 @@ +package computer + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + + "github.com/MixinNetwork/bot-api-go-client/v3" + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" +) + +const ( + RequestRoleUser = common.RequestRoleHolder + RequestRoleSigner = common.RequestRoleSigner + RequestRoleObserver = common.RequestRoleObserver + + FlagConfirmCallSuccess = 1 + FlagConfirmCallFail = 2 + + // user operation + OperationTypeAddUser = 1 + OperationTypeSystemCall = 2 + OperationTypeUserDeposit = 3 + + // observer operation + OperationTypeSetOperationParams = 10 + OperationTypeKeygenInput = 11 + OperationTypeDeployExternalAssets = 12 + OperationTypeDeposit = 13 + OperationTypeConfirmNonce = 14 + OperationTypeConfirmCall = 16 + OperationTypeSignInput = 17 + + // signer operation + OperationTypeKeygenOutput = 20 + OperationTypeSignPrepare = 21 + OperationTypeSignOutput = 22 +) + +func decodeRequest(out *mtg.Action, extra []byte, role uint8) (*store.Request, error) { + h, err := crypto.HashFromString(out.TransactionHash) + if err != nil { + return nil, err + } + r := &store.Request{ + Id: out.OutputId, + Action: extra[0], + ExtraHEX: hex.EncodeToString(extra[1:]), + MixinHash: h, + MixinIndex: out.OutputIndex, + AssetId: out.AssetId, + Amount: out.Amount, + Role: role, + State: common.RequestStateInitial, + CreatedAt: out.SequencerCreatedAt, + Sequence: out.Sequence, + + Output: out, + } + return r, r.VerifyFormat() +} + +func (node *Node) parseRequest(out *mtg.Action) (*store.Request, error) { + switch { + case node.conf.AssetId == out.AssetId: + if out.Amount.Cmp(decimal.NewFromInt(1)) < 0 { + panic(out.TransactionHash) + } + return node.parseSignerResponse(out) + case bot.XINAssetId == out.AssetId && node.verifyObserverRequest(out): + return node.parseObserverRequest(out) + default: + return node.parseUserRequest(out) + } +} + +func (node *Node) signObserverExtra(extra []byte) []byte { + key := crypto.Key(common.DecodeHexOrPanic(node.conf.MTG.App.SpendPrivateKey)) + msg := crypto.Sha256Hash(extra) + sig := key.Sign(msg) + return append(sig[:], extra...) +} + +func (node *Node) verifyObserverRequest(out *mtg.Action) bool { + _, extra := mtg.DecodeMixinExtraHEX(out.Extra) + if len(extra) < 65 { + return false + } + pub := crypto.Key(common.DecodeHexOrPanic(node.conf.ObserverPublicKey)) + sig := crypto.Signature(extra[:64]) + hash := crypto.Sha256Hash(extra[64:]) + return pub.Verify(hash, sig) +} + +func (node *Node) parseObserverRequest(out *mtg.Action) (*store.Request, error) { + if len(out.Senders) != 1 || !node.IsMember(out.Senders[0]) { + return nil, fmt.Errorf("parseObserverRequest(%v) %s", out, strings.Join(out.Senders, ",")) + } + a, m := mtg.DecodeMixinExtraHEX(out.Extra) + if a != node.conf.AppId { + panic(out.Extra) + } + if len(m) < 2 { + return nil, fmt.Errorf("node.parseObserverRequest(%v)", out) + } + return decodeRequest(out, m[64:], RequestRoleObserver) +} + +func (node *Node) parseSignerResponse(out *mtg.Action) (*store.Request, error) { + if len(out.Senders) != 1 || !node.IsMember(out.Senders[0]) { + return nil, fmt.Errorf("parseSignerResponse(%v) %s", out, strings.Join(out.Senders, ",")) + } + a, m := mtg.DecodeMixinExtraHEX(out.Extra) + if a != node.conf.AppId { + panic(out.Extra) + } + if len(m) < 12 { + return nil, fmt.Errorf("node.parseSignerResponse(%v)", out) + } + return decodeRequest(out, m, RequestRoleSigner) +} + +func (node *Node) parseUserRequest(out *mtg.Action) (*store.Request, error) { + a, m := mtg.DecodeMixinExtraHEX(out.Extra) + if a != node.conf.AppId { + panic(out.Extra) + } + if len(m) == 0 { + return nil, fmt.Errorf("node.parseUserRequest(%v)", out) + } + return decodeRequest(out, m, RequestRoleUser) +} + +func (node *Node) buildRefundTxs(ctx context.Context, req *store.Request, am []*ReferencedTxAsset, receivers []string, threshold int) ([]*mtg.Transaction, string) { + var txs []*mtg.Transaction + for _, as := range am { + assetId := uuid.Must(uuid.FromString(as.AssetId)).String() + memo := fmt.Sprintf("refund:%s", assetId) + trace := common.UniqueId(req.Id, memo) + t := node.buildTransaction(ctx, req.Output, node.conf.AppId, assetId, receivers, threshold, as.Amount.String(), []byte(memo), trace) + if t == nil { + // TODO then all other assets ignored? + return nil, assetId + } + txs = append(txs, t) + } + return txs, "" +} + +func (node *Node) failRequest(ctx context.Context, req *store.Request, assetId string) ([]*mtg.Transaction, string) { + logger.Printf("node.failRequest(%v, %s)", req, assetId) + err := node.store.FailRequest(ctx, req, assetId, nil) + if err != nil { + panic(err) + } + return nil, assetId +} diff --git a/computer/rpc.go b/computer/rpc.go new file mode 100644 index 00000000..1498e92e --- /dev/null +++ b/computer/rpc.go @@ -0,0 +1,300 @@ +package computer + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/MixinNetwork/mixin/logger" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" +) + +func (node *Node) checkCreatedAtaUntilSufficient(ctx context.Context, tx *solana.Transaction) error { + as := solanaApp.ExtractCreatedAtasFromTransaction(ctx, tx) + for _, ata := range as { + for { + acc, err := node.RPCGetAccount(ctx, ata) + if err != nil { + return err + } + if acc != nil { + break + } + time.Sleep(time.Second) + } + } + return nil +} + +func (node *Node) checkMintsUntilSufficient(ctx context.Context, ts []*solanaApp.TokenTransfer) error { + for _, t := range ts { + for { + acc, err := node.RPCGetAccount(ctx, t.Mint) + if err != nil { + return err + } + if acc != nil { + break + } + time.Sleep(time.Second) + } + } + return nil +} + +func (node *Node) SendTransactionUtilConfirm(ctx context.Context, tx *solana.Transaction, call *store.SystemCall) (*rpc.GetTransactionResult, error) { + id := "" + if call != nil { + id = call.RequestId + } + + hash := tx.Signatures[0].String() + retry := SolanaTxRetry + for { + rpcTx, err := node.RPCGetTransaction(ctx, hash) + if err != nil { + return nil, fmt.Errorf("solana.RPCGetTransaction(%s) => %v", hash, err) + } + if rpcTx != nil { + return rpcTx, nil + } + + sig, sendError := node.solana.SendTransaction(ctx, tx) + logger.Printf("solana.SendTransaction(%s) => %s %v", id, sig, sendError) + if sendError == nil { + retry -= 1 + time.Sleep(500 * time.Millisecond) + continue + } + if strings.Contains(sendError.Error(), "Blockhash not found") { + // retry when observer send tx without nonce account + if call == nil { + retry -= 1 + if retry > 0 { + time.Sleep(5 * time.Second) + continue + } + return nil, sendError + } + + // outdated nonce account hash when sending tx at first time + if retry == SolanaTxRetry { + return nil, sendError + } + } + + rpcTx, err = node.RPCGetTransaction(ctx, hash) + logger.Printf("solana.RPCGetTransaction(%s) => %v", hash, err) + if err != nil { + return nil, fmt.Errorf("solana.RPCGetTransaction(%s) => %v", hash, err) + } + // transaction confirmed after re-sending failure + if rpcTx != nil { + return rpcTx, nil + } + + retry -= 1 + if retry > 0 { + time.Sleep(500 * time.Millisecond) + continue + } + return nil, sendError + } +} + +func (node *Node) GetPayerBalance(ctx context.Context) (uint64, error) { + return node.solana.RPCGetBalance(ctx, node.SolanaPayer()) +} + +func (node *Node) RPCGetTransaction(ctx context.Context, signature string) (*rpc.GetTransactionResult, error) { + key := fmt.Sprintf("getTransaction:%s", signature) + value, err := node.store.ReadCache(ctx, key) + if err != nil { + panic(err) + } + + if value != "" { + var r rpc.GetTransactionResult + err = json.Unmarshal(common.DecodeHexOrPanic(value), &r) + if err != nil { + panic(err) + } + return &r, nil + } + + tx, err := node.solana.RPCGetTransaction(ctx, signature) + if err != nil { + panic(err) + } + if tx == nil { + return nil, nil + } + b, err := json.Marshal(tx) + if err != nil { + panic(err) + } + err = node.store.WriteCache(ctx, key, hex.EncodeToString(b)) + if err != nil { + panic(err) + } + return tx, nil +} + +func (node *Node) RPCGetAccount(ctx context.Context, account solana.PublicKey) (*rpc.GetAccountInfoResult, error) { + key := fmt.Sprintf("getAccount:%s", account.String()) + value, err := node.store.ReadCache(ctx, key) + if err != nil { + panic(err) + } + + if value != "" { + var r rpc.GetAccountInfoResult + err = json.Unmarshal(common.DecodeHexOrPanic(value), &r) + if err != nil { + panic(err) + } + return &r, nil + } + + acc, err := node.solana.RPCGetAccount(ctx, account) + if err != nil { + panic(err) + } + if acc == nil { + return nil, nil + } + b, err := json.Marshal(acc) + if err != nil { + panic(err) + } + err = node.store.WriteCache(ctx, key, hex.EncodeToString(b)) + if err != nil { + panic(err) + } + return acc, nil +} + +func (node *Node) RPCGetMultipleAccounts(ctx context.Context, as solana.PublicKeySlice) (*rpc.GetMultipleAccountsResult, error) { + accounts, err := node.solana.RPCGetMultipleAccounts(ctx, as) + if err != nil { + return nil, err + } + for index, acc := range accounts.Value { + if acc == nil { + continue + } + account := &rpc.GetAccountInfoResult{ + RPCContext: accounts.RPCContext, + Value: acc, + } + key := fmt.Sprintf("getAccountInfo:%s", as[index].String()) + b, err := json.Marshal(account) + if err != nil { + panic(err) + } + err = node.store.WriteCache(ctx, key, hex.EncodeToString(b)) + if err != nil { + panic(err) + } + } + return accounts, nil +} + +func (node *Node) RPCGetAsset(ctx context.Context, account string) (*solanaApp.Asset, error) { + key := fmt.Sprintf("getAsset:%s", account) + value, err := node.store.ReadCache(ctx, key) + if err != nil { + panic(err) + } + + if value != "" { + var a solanaApp.Asset + err = json.Unmarshal(common.DecodeHexOrPanic(value), &a) + if err != nil { + panic(err) + } + return &a, nil + } + + asset, err := node.solana.RPCGetAsset(ctx, account) + if err != nil { + panic(err) + } + if asset == nil { + return nil, nil + } + b, err := json.Marshal(asset) + if err != nil { + panic(err) + } + err = node.store.WriteCache(ctx, key, hex.EncodeToString(b)) + if err != nil { + panic(err) + } + return asset, nil +} + +func (node *Node) RPCGetBlockByHeight(ctx context.Context, height uint64) (*rpc.GetBlockResult, error) { + key := fmt.Sprintf("getBlock:%d", height) + value, err := node.store.ReadCache(ctx, key) + if err != nil { + panic(err) + } + + if value != "" { + var b rpc.GetBlockResult + err = json.Unmarshal(common.DecodeHexOrPanic(value), &b) + if err != nil { + panic(err) + } + return &b, nil + } + + block, err := node.solana.RPCGetBlockByHeight(ctx, height) + if err != nil { + return nil, err + } + b, err := json.Marshal(block) + if err != nil { + panic(err) + } + err = node.store.WriteCache(ctx, key, hex.EncodeToString(b)) + if err != nil { + panic(err) + } + return block, nil +} + +func (node *Node) RPCGetMinimumBalanceForRentExemption(ctx context.Context, dataSize uint64) (uint64, error) { + key := fmt.Sprintf("getMinimumBalanceForRentExemption:%d", dataSize) + value, err := node.store.ReadCache(ctx, key) + if err != nil { + panic(err) + } + + if value != "" { + num, err := strconv.ParseUint(value, 10, 64) + if err != nil { + panic(err) + } + return num, nil + } + + rentExemptBalance, err := node.solana.RPCGetMinimumBalanceForRentExemption(ctx, dataSize) + if err != nil { + return 0, fmt.Errorf("soalan.GetMinimumBalanceForRentExemption(%d) => %v", dataSize, err) + } + err = node.store.WriteCache(ctx, key, fmt.Sprintf("%d", rentExemptBalance)) + if err != nil { + panic(err) + } + return rentExemptBalance, nil +} diff --git a/computer/signer.go b/computer/signer.go new file mode 100644 index 00000000..d36da64e --- /dev/null +++ b/computer/signer.go @@ -0,0 +1,804 @@ +package computer + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "fmt" + "runtime" + "slices" + "time" + + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/multi-party-sig/common/round" + "github.com/MixinNetwork/multi-party-sig/pkg/math/curve" + "github.com/MixinNetwork/multi-party-sig/pkg/party" + "github.com/MixinNetwork/multi-party-sig/protocols/frost" + "github.com/MixinNetwork/safe/apps/mixin" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/MixinNetwork/safe/mtg" + "github.com/MixinNetwork/safe/signer/protocol" + "github.com/gofrs/uuid/v5" +) + +const ( + SessionTimeout = time.Hour + MPCFirstMessageRound = 2 +) + +var PrepareExtra = []byte("PREPARE") + +func (node *Node) bootSigner(ctx context.Context) { + go node.loopInitialSessions(ctx) + go node.loopPreparedSessions(ctx) + go node.loopPendingSessions(ctx) + go node.acceptIncomingMessages(ctx) +} + +func (node *Node) loopInitialSessions(ctx context.Context) { + for { + time.Sleep(3 * time.Second) + synced := node.synced(ctx) + if !synced { + logger.Printf("group.Synced(%s) => %t", node.group.GenesisId(), synced) + continue + } + sessions, err := node.store.ListInitialSessions(ctx, 64) + if err != nil { + panic(err) + } + + for _, s := range sessions { + traceId := fmt.Sprintf("SESSION:%s:SIGNER:%s:PREPARE", s.Id, string(node.id)) + extra := uuid.Must(uuid.FromString(s.Id)).Bytes() + extra = append(extra, PrepareExtra...) + op := &common.Operation{ + Type: OperationTypeSignPrepare, + Extra: extra, + } + err := node.sendSignerTransactionToGroup(ctx, traceId, op, nil) + logger.Printf("node.sendSignerTransactionToGroup(%v) => %v", op, err) + if err != nil { + break + } + err = node.store.MarkSessionCommitted(ctx, s.Id) + logger.Printf("node.MarkSessionCommitted(%v) => %v", s, err) + if err != nil { + break + } + } + } +} + +func (node *Node) loopPreparedSessions(ctx context.Context) { + for { + time.Sleep(3 * time.Second) + synced := node.synced(ctx) + if !synced { + logger.Printf("group.Synced(%s) => %t", node.group.GenesisId(), synced) + continue + } + sessions := node.listPreparedSessions(ctx) + results := make([]<-chan error, len(sessions)) + for i, s := range sessions { + threshold := node.threshold + 1 + signers, err := node.store.ListSessionPreparedMembers(ctx, s.Id, threshold) + if err != nil { + panic(err) + } + if len(signers) != threshold && s.Operation != OperationTypeKeygenInput { + panic(fmt.Sprintf("ListSessionPreparedMember(%s, %d) => %d", s.Id, threshold, len(signers))) + } + results[i] = node.queueOperation(ctx, s.AsOperation(), signers) + } + for _, res := range results { + if res == nil { + continue + } + if err := <-res; err != nil { + panic(err) + } + } + } +} + +func (node *Node) listPreparedSessions(ctx context.Context) []*store.Session { + parallelization := runtime.NumCPU() * (len(node.GetMembers())/16 + 1) + + var sessions []*store.Session + prepared, err := node.store.ListPreparedSessions(ctx, parallelization*4) + if err != nil { + panic(err) + } + for _, s := range prepared { + if s.CreatedAt.Add(SessionTimeout).Before(time.Now().UTC()) { + err = node.store.FailSession(ctx, s.Id) + logger.Printf("store.FailSession(%s, listPreparedSessions) => %v", s.Id, err) + if err != nil { + panic(err) + } + continue + } + sessions = append(sessions, s) + if len(sessions) == parallelization { + break + } + } + return sessions +} + +func (node *Node) loopPendingSessions(ctx context.Context) { + for { + time.Sleep(3 * time.Second) + synced := node.synced(ctx) + if !synced { + logger.Printf("group.Synced(%s) => %t", node.group.GenesisId(), synced) + continue + } + sessions, err := node.store.ListPendingSessions(ctx, 64) + if err != nil { + panic(err) + } + + for _, s := range sessions { + op := s.AsOperation() + switch op.Type { + case OperationTypeKeygenInput: + op.Extra = common.DecodeHexOrPanic(op.Public) + op.Type = OperationTypeKeygenOutput + case OperationTypeSignInput: + public, share, path, err := node.readKeyByFingerPath(ctx, op.Public) + logger.Printf("node.readKeyByFingerPath(%s) => %s %v", op.Public, public, err) + if err != nil || public == "" { + panic(fmt.Errorf("node.readKeyByFingerPath(%s) => %s %v", op.Public, public, err)) + } + call, err := node.store.ReadSystemCallByRequestId(ctx, s.RequestId, 0) + if err != nil { + panic(err) + } + if call == nil || call.State != common.RequestStatePending { + err = node.store.MarkSessionDone(ctx, s.Id) + logger.Printf("node.MarkSessionDone(%v) => %v", s, err) + if err != nil { + panic(err) + } + break + } + signed, sig := node.verifySessionSignature(call.MessageBytes(), op.Extra, share, path) + if signed { + op.Extra = sig + } else { + op.Extra = nil + } + op.Type = OperationTypeSignOutput + default: + panic(op.Id) + } + + traceId := fmt.Sprintf("SESSION:%s:SIGNER:%s:RESULT", op.Id, string(node.id)) + extra := op.IdBytes() + extra = append(extra, op.Extra...) + err := node.sendSignerTransactionToGroup(ctx, traceId, &common.Operation{ + Type: op.Type, + Extra: extra, + }, nil) + if err != nil { + break + } + err = node.store.MarkSessionDone(ctx, op.Id) + logger.Printf("node.MarkSessionDone(%v) => %v", op, err) + if err != nil { + break + } + } + } +} + +func (node *Node) acceptIncomingMessages(ctx context.Context) { + for { + mm, err := node.network.ReceiveMessage(ctx) + logger.Debugf("network.ReceiveMessage() => %s %x %s %v", mm.Peer, mm.Data, mm.CreatedAt, err) + if err != nil { + panic(err) + } + sessionId, msg, err := unmarshalSessionMessage(mm.Data) + logger.Verbosef("node.acceptIncomingMessages(%x, %d) => %s %s %x", sessionId, msg.RoundNumber, mm.Peer, mm.CreatedAt, msg.SSID) + if err != nil { + continue + } + if msg.SSID == nil { + continue + } + if msg.From != party.ID(mm.Peer) { + continue + } + if !msg.IsFor(node.id) { + continue + } + mps := node.getSession(sessionId) + // TODO verify msg signature by sender public key + mps.incoming <- msg + if msg.RoundNumber != MPCFirstMessageRound { + continue + } + + id := uuid.Must(uuid.FromBytes(sessionId)) + r, err := node.store.ReadSession(ctx, id.String()) + if err != nil { + panic(err) + } + if r == nil { + continue + } + threshold := node.threshold + 1 + signers, err := node.store.ListSessionPreparedMembers(ctx, r.Id, threshold) + if err != nil { + panic(err) + } + if len(signers) < threshold { + continue + } + if r.State == common.RequestStateInitial { + node.queueOperation(ctx, &common.Operation{ + Id: r.Id, + Type: r.Operation, + Public: r.Public, + Extra: common.DecodeHexOrPanic(r.Extra), + }, signers) + } else { + rm := &protocol.Message{SSID: sessionId, From: node.id, To: party.ID(mm.Peer)} + rmb := marshalSessionMessage(sessionId, rm) + err := node.network.QueueMessage(ctx, mm.Peer, rmb) + logger.Verbosef("network.QueueMessage(%x, %d) => %s %v", mps.id, msg.RoundNumber, id, err) + } + } +} + +func (node *Node) queueOperation(ctx context.Context, op *common.Operation, members []party.ID) <-chan error { + node.mutex.Lock() + defer node.mutex.Unlock() + + if node.operations[op.Id] { + return nil + } + node.operations[op.Id] = true + + res := make(chan error) + go func() { res <- node.startOperation(ctx, op, members) }() + return res +} + +func (node *Node) handlerLoop(ctx context.Context, start round.Session, sessionId []byte, roundTimeout time.Duration) (any, error) { + logger.Printf("node.handlerLoop(%x) => %x", sessionId, start.SSID()) + h, err := protocol.NewMultiHandler(start) + if err != nil { + return nil, err + } + mps := node.getSession(sessionId) + mps.members = start.PartyIDs() + + res, err := node.loopMultiPartySession(ctx, mps, h, roundTimeout) + missing := mps.missing(node.id) + logger.Printf("node.loopMultiPartySession(%x, %d) => %v with %v missing", mps.id, mps.round, err, missing) + return res, err +} + +func (node *Node) loopMultiPartySession(ctx context.Context, mps *MultiPartySession, h protocol.Handler, roundTimeout time.Duration) (any, error) { + for { + select { + case msg, ok := <-h.Listen(): + if !ok { + return h.Result() + } + msb := marshalSessionMessage(mps.id, msg) + for _, id := range mps.members { + if !msg.IsFor(id) { + continue + } + err := node.network.QueueMessage(ctx, string(id), msb) + logger.Verbosef("network.QueueMessage(%x, %d) => %s %v", mps.id, msg.RoundNumber, id, err) + } + mps.advance(msg) + mps.process(ctx, h, node.store) + case msg := <-mps.incoming: + logger.Verbosef("network.incoming(%x, %d) %s", mps.id, msg.RoundNumber, msg.From) + if !mps.findMember(msg.From) { + continue + } + if bytes.Equal(mps.id, msg.SSID) { + return nil, fmt.Errorf("node.handlerLoop(%x) expired from %s", mps.id, msg.From) + } + mps.receive(msg) + mps.process(ctx, h, node.store) + case <-time.After(roundTimeout): + return nil, fmt.Errorf("node.handlerLoop(%x) timeout", mps.id) + } + } +} + +type MultiPartySession struct { + id []byte + members []party.ID + incoming chan *protocol.Message + received map[round.Number][]*protocol.Message + accepted map[round.Number][]*protocol.Message + round round.Number +} + +func (mps *MultiPartySession) findMember(id party.ID) bool { + return slices.Contains(mps.members, id) +} + +func (mps *MultiPartySession) missing(self party.ID) []party.ID { + var missing []party.ID + accepted := mps.accepted[mps.round] + for _, id := range mps.members { + if id == self { + continue + } + if !slices.ContainsFunc(accepted, func(m *protocol.Message) bool { + return m.From == id + }) { + missing = append(missing, id) + } + } + return missing +} + +func (mps *MultiPartySession) advance(msg *protocol.Message) { + logger.Printf("MultiPartySession.advance(%x, %d) => %d", mps.id, mps.round, msg.RoundNumber) + if mps.round < msg.RoundNumber { + mps.round = msg.RoundNumber + } +} + +func (mps *MultiPartySession) receive(msg *protocol.Message) { + mps.received[msg.RoundNumber] = append(mps.received[msg.RoundNumber], msg) +} + +func (mps *MultiPartySession) process(ctx context.Context, h protocol.Handler, store *store.SQLite3Store) { + for i, msg := range mps.received[mps.round] { + if msg == nil || !h.CanAccept(msg) { + continue + } + logger.Verbosef("handler.CanAccept(%x, %d) => %s", mps.id, msg.RoundNumber, msg.From) + accepted := h.Accept(msg) + logger.Verbosef("handler.Accept(%x, %d) => %s %t", mps.id, msg.RoundNumber, msg.From, accepted) + if !accepted { + continue + } + sid := uuid.Must(uuid.FromBytes(mps.id)).String() + extra := common.MarshalPanic(msg) + err := store.WriteSessionWorkIfNotExist(ctx, sid, string(msg.From), int(msg.RoundNumber), extra) + logger.Verbosef("store.WriteSessionWorkIfNotExist(%s, %s, %d) => %v", sid, msg.From, msg.RoundNumber, err) + if err != nil { + panic(err) + } + mps.accepted[msg.RoundNumber] = append(mps.accepted[msg.RoundNumber], msg) + mps.received[mps.round][i] = nil + } +} + +func (node *Node) getSession(sessionId []byte) *MultiPartySession { + node.mutex.Lock() + defer node.mutex.Unlock() + + sid := hex.EncodeToString(sessionId) + session := node.sessions[sid] + + members := node.GetMembers() + if session == nil { + size := len(members) * len(members) + session = &MultiPartySession{ + id: sessionId, + round: MPCFirstMessageRound, + incoming: make(chan *protocol.Message, size), + received: make(map[round.Number][]*protocol.Message), + accepted: make(map[round.Number][]*protocol.Message), + } + node.sessions[sid] = session + } + return session +} + +func marshalSessionMessage(sessionId []byte, msg *protocol.Message) []byte { + if len(sessionId) > 32 { + panic(hex.EncodeToString(sessionId)) + } + msb := []byte{byte(len(sessionId))} + msb = append(msb, sessionId...) + return append(msb, common.MarshalPanic(msg)...) +} + +func unmarshalSessionMessage(b []byte) ([]byte, *protocol.Message, error) { + if len(b) < 16 { + return nil, nil, fmt.Errorf("unmarshalSessionMessage(%x) short", b) + } + if len(b[1:]) <= int(b[0]) { + return nil, nil, fmt.Errorf("unmarshalSessionMessage(%x) short", b) + } + sessionId := b[1 : 1+b[0]] + var msg protocol.Message + err := msg.UnmarshalBinary(b[1+b[0]:]) + return sessionId, &msg, err +} + +func (node *Node) readKeyByFingerPath(ctx context.Context, public string) (string, []byte, []byte, error) { + fingerPath, err := hex.DecodeString(public) + if err != nil || len(fingerPath) != 16 { + return "", nil, nil, fmt.Errorf("node.readKeyByFingerPath(%s) invalid fingerprint", public) + } + fingerprint := hex.EncodeToString(fingerPath[:8]) + public, share, err := node.store.ReadKeyByFingerprint(ctx, fingerprint) + return public, share, fingerPath[8:], err +} + +func (node *Node) verifySessionHolder(_ context.Context, holder string) bool { + point := curve.Edwards25519Point{} + err := point.UnmarshalBinary(common.DecodeHexOrPanic(holder)) + return err == nil +} + +func (node *Node) verifySessionSignature(msg, sig, share, path []byte) (bool, []byte) { + public, _ := node.deriveByPath(share, path) + pub := ed25519.PublicKey(public) + res := ed25519.Verify(pub, msg, sig) + logger.Printf("ed25519.Verify(%x, %x) => %t", msg, sig[:], res) + return res, sig +} + +func (node *Node) verifySessionSignerResults(_ context.Context, session *store.Session, sessionSigners map[string]string) (bool, []byte) { + members := node.GetMembers() + switch session.Operation { + case OperationTypeKeygenInput: + var signed int + for _, id := range members { + public, found := sessionSigners[id] + if found && public == session.Public && public == sessionSigners[string(node.id)] { + signed = signed + 1 + } + } + exact := len(members) + return signed >= exact, nil + case OperationTypeSignInput: + var signed int + var sig []byte + for _, id := range members { + extra, found := sessionSigners[id] + if sig == nil && found { + sig = common.DecodeHexOrPanic(extra) + } + if found && extra != "" && hex.EncodeToString(sig) == extra { + signed = signed + 1 + } + } + exact := node.threshold + 1 + return signed >= exact, sig + default: + panic(session.Id) + } +} + +func (node *Node) startOperation(ctx context.Context, op *common.Operation, members []party.ID) error { + logger.Printf("node.startOperation(%v)", op) + + switch op.Type { + case OperationTypeKeygenInput: + return node.startKeygen(ctx, op) + case OperationTypeSignInput: + return node.startSign(ctx, op, members) + default: + panic(op.Id) + } +} + +func (node *Node) startKeygen(ctx context.Context, op *common.Operation) error { + logger.Printf("node.startKeygen(%v)", op) + res, err := node.frostKeygen(ctx, op.IdBytes(), curve.Edwards25519{}) + logger.Printf("node.frostKeygen(%v) => %v", op, err) + if err != nil { + return node.store.FailSession(ctx, op.Id) + } + + op.Public = hex.EncodeToString(res.Public) + if common.CheckTestEnvironment(ctx) { + extra := []byte{OperationTypeKeygenOutput} + extra = append(extra, []byte(op.Public)...) + err = node.store.WriteProperty(ctx, "SIGNER:"+op.Id, hex.EncodeToString(extra)) + if err != nil { + panic(err) + } + } + session, err := node.store.ReadSession(ctx, op.Id) + if err != nil { + panic(err) + } + return node.store.WriteKeyIfNotExists(ctx, session, op.Public, res.Share, false) +} + +func (node *Node) startSign(ctx context.Context, op *common.Operation, members []party.ID) error { + logger.Printf("node.startSign(%v, %v, %s)\n", op, members, string(node.id)) + if !slices.Contains(members, node.id) { + logger.Printf("node.startSign(%v, %v, %s) exit without committement\n", op, members, string(node.id)) + return nil + } + public, share, path, err := node.readKeyByFingerPath(ctx, op.Public) + logger.Printf("node.readKeyByFingerPath(%s) => %s %v", op.Public, public, err) + if err != nil { + return fmt.Errorf("node.readKeyByFingerPath(%s) => %v", op.Public, err) + } + if public == "" { + return node.store.FailSession(ctx, op.Id) + } + fingerprint := op.Public[:16] + if hex.EncodeToString(common.Fingerprint(public)) != fingerprint { + return fmt.Errorf("node.startSign(%v) invalid sum %x %s", op, common.Fingerprint(public), fingerprint) + } + + res, err := node.frostSign(ctx, members, public, share, op.Extra, op.IdBytes(), curve.Edwards25519{}, path) + logger.Printf("node.frostSign(%v) => %v %v", op, res, err) + if err != nil { + err = node.store.FailSession(ctx, op.Id) + logger.Printf("store.FailSession(%s, startSign) => %v", op.Id, err) + return err + } + + if common.CheckTestEnvironment(ctx) { + extra := []byte{OperationTypeSignOutput} + extra = append(extra, res.Signature...) + err = node.store.WriteProperty(ctx, "SIGNER:"+op.Id, hex.EncodeToString(extra)) + if err != nil { + panic(err) + } + } + err = node.store.MarkSessionPending(ctx, op.Id, op.Public, res.Signature) + logger.Printf("store.MarkSessionPending(%v, startSign) => %x %v\n", op, res.Signature, err) + return err +} + +func (node *Node) deriveByPath(share, path []byte) ([]byte, []byte) { + conf := frost.EmptyConfig(curve.Edwards25519{}) + err := conf.UnmarshalBinary(share) + if err != nil { + panic(err) + } + pub := common.MarshalPanic(conf.PublicPoint()) + if mixin.CheckEd25519ValidChildPath(path) { + conf = deriveEd25519Child(conf, pub, path) + pub = common.MarshalPanic(conf.PublicPoint()) + } + return pub, conf.ChainKey +} + +func (node *Node) processSignerKeygenResults(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleSigner { + panic(req.Role) + } + if req.Action != OperationTypeKeygenOutput { + panic(req.Action) + } + + extra := req.ExtraBytes() + sid := uuid.FromBytesOrNil(extra[:16]).String() + public := extra[16:] + + s, err := node.store.ReadSession(ctx, sid) + logger.Printf("store.ReadSession(%s) => %v %v", sid, s, err) + if err != nil { + panic(err) + } + fp := hex.EncodeToString(common.Fingerprint(hex.EncodeToString(public))) + key, _, err := node.store.ReadKeyByFingerprint(ctx, fp) + logger.Printf("store.ReadKeyByFingerprint(%s) => %s %v", fp, key, err) + if err != nil || key == "" { + panic(err) + } + if key != hex.EncodeToString(public) { + panic(key) + } + + sender := req.Output.Senders[0] + err = node.store.WriteSessionSignerIfNotExist(ctx, s.Id, sender, public, req.Output.SequencerCreatedAt, sender == string(node.id)) + if err != nil { + panic(fmt.Errorf("store.WriteSessionSignerIfNotExist(%v) => %v", s, err)) + } + signers, err := node.store.ListSessionSignerResults(ctx, s.Id) + if err != nil { + panic(fmt.Errorf("store.ListSessionSignerResults(%s) => %d %v", s.Id, len(signers), err)) + } + finished, sig := node.verifySessionSignerResults(ctx, s, signers) + logger.Printf("node.verifySessionSignerResults(%v, %d) => %t %x", s, len(signers), finished, sig) + if !finished { + return node.failRequest(ctx, req, "") + } + if l := len(signers); l <= node.threshold { + panic(s.Id) + } + + valid := node.verifySessionHolder(ctx, hex.EncodeToString(public)) + logger.Printf("node.verifySessionHolder(%x) => %t", public, valid) + if !valid { + return nil, "" + } + + err = node.store.MarkKeyConfirmedWithRequest(ctx, req, hex.EncodeToString(public)) + if err != nil { + panic(fmt.Errorf("store.MarkKeyConfirmedWithRequest(%v) => %v", req, err)) + } + return nil, "" +} + +func (node *Node) processSignerKeygenRequests(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleObserver { + panic(req.Role) + } + if req.Action != OperationTypeKeygenInput { + panic(req.Action) + } + + extra := req.ExtraBytes() + if len(extra) != 1 { + return node.failRequest(ctx, req, "") + } + count, err := node.store.CountKeys(ctx) + logger.Printf("store.CountKeys() => %v %d:%d:%d", err, count, extra[0], node.conf.MPCKeyNumber) + if err != nil { + panic(err) + } + if int(extra[0]) != count || count >= node.conf.MPCKeyNumber { + return node.failRequest(ctx, req, "") + } + + members := node.GetMembers() + threshold := node.conf.MTG.Genesis.Threshold + id := common.UniqueId(req.Id, fmt.Sprintf("OperationTypeKeygenInput:%d", count)) + id = common.UniqueId(id, fmt.Sprintf("MTG:%v:%d", members, threshold)) + sessions := []*store.Session{{ + Id: id, + RequestId: req.Id, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeKeygenInput, + CreatedAt: req.CreatedAt, + }} + err = node.store.WriteSessionsWithRequest(ctx, req, sessions, false) + if err != nil { + panic(fmt.Errorf("store.WriteSessionsWithRequest(%v) => %v", req, err)) + } + return nil, "" +} + +func (node *Node) processSignerPrepare(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + if req.Role != RequestRoleSigner { + panic(req.Role) + } + if req.Action != OperationTypeSignPrepare { + panic(req.Action) + } + + extra := req.ExtraBytes() + session := uuid.Must(uuid.FromBytes(extra[:16])).String() + extra = extra[16:] + if !bytes.Equal(extra, PrepareExtra) { + logger.Printf("invalid prepare extra: %s", string(extra)) + return node.failRequest(ctx, req, "") + } + + s, err := node.store.ReadSession(ctx, session) + if err != nil { + panic(fmt.Errorf("store.ReadSession(%s) => %v %v", session, s, err)) + } + if s == nil || s.PreparedAt.Valid { + logger.Printf("invalid session %v", s) + return node.failRequest(ctx, req, "") + } + + err = node.store.PrepareSessionSignerIfNotExist(ctx, s.Id, req.Output.Senders[0], req.Output.SequencerCreatedAt) + logger.Printf("store.PrepareSessionSignerIfNotExist(%s %s %s) => %v", s.Id, node.id, req.Output.Senders[0], err) + if err != nil { + panic(fmt.Errorf("store.PrepareSessionSignerIfNotExist(%v) => %v", s, err)) + } + signers, err := node.store.ListSessionSignerResults(ctx, s.Id) + logger.Printf("store.ListSessionSignerResults(%s) => %d %v", s.Id, len(signers), err) + if err != nil { + panic(fmt.Errorf("store.ListSessionSignerResults(%s) => %v", s.Id, err)) + } + if len(signers) <= node.threshold { + logger.Printf("insufficient prepared signers: %d %d", len(signers), node.threshold) + return node.failRequest(ctx, req, "") + } + err = node.store.MarkSessionPreparedWithRequest(ctx, req, s.Id, req.Output.SequencerCreatedAt) + if err != nil { + panic(fmt.Errorf("node.MarkSessionPreparedWithRequest(%s %v) => %v", s.Id, req.Output.SequencerCreatedAt, err)) + } + return nil, "" +} + +func (node *Node) processSignerSignatureResponse(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { + logger.Printf("node.processSignerSignatureResponse(%s)", string(node.id)) + if req.Role != RequestRoleSigner { + panic(req.Role) + } + if req.Action != OperationTypeSignOutput { + panic(req.Action) + } + extra := req.ExtraBytes() + sid := uuid.FromBytesOrNil(extra[:16]).String() + signature := extra[16:] + s, err := node.store.ReadSession(ctx, sid) + if err != nil || s == nil { + panic(fmt.Errorf("store.ReadSession(%s) => %v %v", sid, s, err)) + } + call, err := node.store.ReadSystemCallByRequestId(ctx, s.RequestId, common.RequestStatePending) + if err != nil || call == nil { + panic(fmt.Errorf("store.ReadSystemCallByRequestId(%s) => %v %v", s.RequestId, call, err)) + } + if call.State == common.RequestStateFailed || call.Signature.Valid { + logger.Printf("invalid call %s: %d %s", call.RequestId, call.State, call.Signature.String) + return node.failRequest(ctx, req, "") + } + + self := len(req.Output.Senders) == 1 && req.Output.Senders[0] == string(node.id) + err = node.store.UpdateSessionSigner(ctx, s.Id, req.Output.Senders[0], signature, req.Output.SequencerCreatedAt, self) + if err != nil { + panic(fmt.Errorf("store.UpdateSessionSigner(%s %s) => %v", s.Id, req.Output.Senders[0], err)) + } + signers, err := node.store.ListSessionSignerResults(ctx, s.Id) + logger.Printf("store.ListSessionSignerResults(%s) => %d", s.Id, len(signers)) + if err != nil { + panic(fmt.Errorf("store.ListSessionSignerResults(%s) => %d %v", s.Id, len(signers), err)) + } + finished, sig := node.verifySessionSignerResults(ctx, s, signers) + logger.Printf("node.verifySessionSignerResults(%v, %d) => %t %x", s, len(signers), finished, sig) + if !finished { + return node.failRequest(ctx, req, "") + } + if l := len(signers); l <= node.threshold { + panic(s.Id) + } + extra = common.DecodeHexOrPanic(s.Extra) + if s.State == common.RequestStateInitial && s.PreparedAt.Valid { + // this could happend only after crash or not commited + err = node.store.MarkSessionPending(ctx, s.Id, s.Public, extra) + logger.Printf("store.MarkSessionPending(%v, processSignerResult) => %x %v\n", s, extra, err) + if err != nil { + panic(err) + } + } + _, share, path, err := node.readKeyByFingerPath(ctx, s.Public) + logger.Printf("node.readKeyByFingerPath(%s) => %v", s.Public, err) + if err != nil { + panic(err) + } + valid, vsig := node.verifySessionSignature(call.MessageBytes(), sig, share, path) + logger.Printf("node.verifySessionSignature(%v, %x) => %t", s, sig, valid) + if !valid || !bytes.Equal(sig, vsig) { + panic(hex.EncodeToString(vsig)) + } + + if common.CheckTestEnvironment(ctx) { + key := "SIGNER:" + sid + val, err := node.store.ReadProperty(ctx, key) + if err != nil { + panic(err) + } + if val == "" { + extra := []byte{OperationTypeSignOutput} + extra = append(extra, signature...) + err = node.store.WriteProperty(ctx, key, hex.EncodeToString(extra)) + if err != nil { + panic(err) + } + } + } + err = node.store.AttachSystemCallSignatureWithRequest(ctx, req, call, s.Id, base64.StdEncoding.EncodeToString(sig)) + if err != nil { + panic(fmt.Errorf("store.AttachSystemCallSignatureWithRequest(%s %v) => %v", s.Id, call, err)) + } + + return nil, "" +} diff --git a/computer/solana.go b/computer/solana.go new file mode 100644 index 00000000..5ab974e1 --- /dev/null +++ b/computer/solana.go @@ -0,0 +1,802 @@ +package computer + +import ( + "context" + "encoding/hex" + "fmt" + "math/big" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/safe/apps/ethereum" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/gagliardetto/solana-go" + lookup "github.com/gagliardetto/solana-go/programs/address-lookup-table" + tokenAta "github.com/gagliardetto/solana-go/programs/associated-token-account" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/rpc" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" +) + +const ( + SolanaBlockDelay = 1 + SolanaBlockBatch = 30 + SolanaTxRetry = 10 +) + +func (node *Node) solanaRPCBlocksLoop(ctx context.Context) { + for { + checkpoint, err := node.readSolanaBlockCheckpoint(ctx) + if err != nil { + panic(err) + } + height, err := node.solana.RPCGetConfirmedHeight(ctx) + if err != nil { + logger.Printf("solana.RPCGetBlockHeight => %v", err) + time.Sleep(time.Second * 5) + continue + } + + rentExemptBalance, err := node.solana.RPCGetMinimumBalanceForRentExemption(ctx, solanaApp.NormalAccountSize) + if err != nil { + panic(err) + } + + var wg sync.WaitGroup + for range SolanaBlockBatch { + checkpoint = checkpoint + 1 + if checkpoint+SolanaBlockDelay > int64(height)+1 { + break + } + wg.Add(1) + go func(current int64) { + defer wg.Done() + err := node.solanaReadBlock(ctx, current, rentExemptBalance) + logger.Printf("node.solanaReadBlock(%d) => %v", current, err) + if err != nil { + panic(err) + } + }(checkpoint) + } + wg.Wait() + + err = node.writeRequestNumber(ctx, store.SolanaScanHeightKey, checkpoint) + if err != nil { + panic(err) + } + } +} + +func (node *Node) solanaReadBlock(ctx context.Context, checkpoint int64, rentExemptBalance uint64) error { + block, err := node.RPCGetBlockByHeight(ctx, uint64(checkpoint)) + if err != nil { + if strings.Contains(err.Error(), "was skipped, or missing") { + return nil + } + return err + } + + for _, tx := range block.Transactions { + err = node.solanaProcessTransaction(ctx, tx.MustGetTransaction(), tx.Meta, rentExemptBalance) + if err != nil { + return err + } + } + return nil +} + +func (node *Node) solanaProcessTransaction(ctx context.Context, tx *solana.Transaction, meta *rpc.TransactionMeta, rentExemptBalance uint64) error { + if meta.Err != nil { + return nil + } + internal := node.checkInternalAccountsFromMeta(ctx, tx, meta) + if !internal { + return nil + } + + hash := tx.Signatures[0] + call, err := node.store.ReadSystemCallByHash(ctx, hash.String()) + if err != nil { + panic(err) + } + var exception *solana.PublicKey + if call != nil { + user := node.getUserSolanaPublicKeyFromCall(ctx, call) + exception = &user + } + + err = node.processTransactionWithAddressLookups(ctx, tx) + if err != nil { + if strings.Contains(err.Error(), "get account info: not found") { + return nil + } + panic(err) + } + // all balance changes from the creator account of a system call is handled in processSuccessedCall + // only process deposits to other user accounts here + transfers, err := solanaApp.ExtractTransfersFromTransaction(ctx, tx, meta, exception) + if err != nil { + panic(err) + } + changes, err := node.parseSolanaBlockBalanceChanges(ctx, transfers) + if err != nil { + logger.Printf("node.parseSolanaBlockBalanceChanges(%s, %d) => %d %v", + hash, len(transfers), len(changes), err) + return err + } + if len(changes) == 0 { + return nil + } + + tsMap := make(map[string][]*solanaApp.TokenTransfer) + for _, transfer := range transfers { + key := fmt.Sprintf("%s:%s", transfer.Receiver, transfer.TokenAddress) + if _, ok := changes[key]; !ok { + continue + } + decimal := uint8(9) + if transfer.TokenAddress != solanaApp.SolanaEmptyAddress { + asset, err := node.RPCGetAsset(ctx, transfer.TokenAddress) + if err != nil { + logger.Printf("solana.RPCGetAsset(%s) => %v", transfer.TokenAddress, err) + return err + } + decimal = uint8(asset.Decimals) + } + if transfer.TokenAddress == solanaApp.SolanaEmptyAddress { + if transfer.Value.Uint64() < 10 { + continue + } + index, err := tx.GetAccountIndex(solana.MustPublicKeyFromBase58(transfer.Receiver)) + if err != nil { + panic(err) + } + if meta.PreBalances[index] <= rentExemptBalance { + continue + } + } + tsMap[transfer.Receiver] = append(tsMap[transfer.Receiver], &solanaApp.TokenTransfer{ + SolanaAsset: true, + AssetId: transfer.AssetId, + ChainId: solanaApp.SolanaChainBase, + Mint: solana.MustPublicKeyFromBase58(transfer.TokenAddress), + Destination: node.solanaDepositEntry(), + Amount: transfer.Value.Uint64(), + Decimals: decimal, + }) + } + for user, ts := range tsMap { + err = node.solanaProcessDepositTransaction(ctx, hash, user, ts) + if err != nil { + logger.Printf("node.solanaProcessDepositTransaction(%s) => %v", hash, err) + return err + } + } + return nil +} + +func (node *Node) solanaProcessDepositTransaction(ctx context.Context, depositHash solana.Signature, user string, ts []*solanaApp.TokenTransfer) error { + id := common.UniqueId(depositHash.String(), user) + cid := common.UniqueId(id, "deposit") + extra := solana.MustPublicKeyFromBase58(user).Bytes() + extra = append(extra, depositHash[:]...) + + nonce, err := node.store.ReadSpareNonceAccount(ctx) + if err != nil { + return err + } + err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) + if err != nil { + return err + } + tx, err := node.solana.TransferOrBurnTokens(ctx, node.SolanaPayer(), solana.MustPublicKeyFromBase58(user), nonce.Account(), ts) + if err != nil { + panic(err) + } + data, err := tx.MarshalBinary() + if err != nil { + panic(err) + } + extra = attachSystemCall(extra, cid, data) + + return node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, + Type: OperationTypeDeposit, + Extra: extra, + }, nil) +} + +func (node *Node) InitializeAccount(ctx context.Context, user *store.User) error { + tx, err := node.solana.InitializeAccount(ctx, node.conf.SolanaKey, user.ChainAddress) + if err != nil { + return err + } + _, err = node.SendTransactionUtilConfirm(ctx, tx, nil) + return err +} + +func (node *Node) CreateMintsTransaction(ctx context.Context, as []string) (string, *solana.Transaction, []*solanaApp.DeployedAsset, error) { + tid := fmt.Sprintf("OBSERVER:%s:MEMBERS:%v:%d", node.id, node.GetMembers(), node.conf.MTG.Genesis.Threshold) + var assets []*solanaApp.DeployedAsset + if common.CheckTestEnvironment(ctx) { + tid = common.UniqueId(tid, common.SafeLitecoinChainId) + ltc, err := common.SafeReadAssetUntilSufficient(ctx, common.SafeLitecoinChainId) + if err != nil { + panic(err) + } + id := fmt.Sprintf("MEMBERS:%v:%d", node.GetMembers(), node.conf.MTG.Genesis.Threshold) + id = common.UniqueId(id, common.SafeLitecoinChainId) + seed := crypto.Sha256Hash(uuid.Must(uuid.FromString(id)).Bytes()) + key := solanaApp.PrivateKeyFromSeed(seed[:]) + assets = []*solanaApp.DeployedAsset{ + { + AssetId: ltc.AssetID, + Address: "EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8", + Uri: "https://uploads.mixin.one/mixin/attachments/1739005826-2dc1afa3f3327f4d29cbb02e3b41cf57d4842f3c444e8e829871699ac43d21b2", + PrivateKey: &key, + Asset: ltc, + }, + } + } else { + for _, asset := range as { + na, err := common.SafeReadAssetUntilSufficient(ctx, asset) + if err != nil { + return "", nil, nil, err + } + uri, err := node.checkExternalAssetUri(ctx, na) + if err != nil { + return "", nil, nil, err + } + tid = common.UniqueId(tid, fmt.Sprintf("metadata-%s", asset)) + id := common.UniqueId(node.group.GenesisId(), tid) + id = common.UniqueId(id, node.SafeUser().SpendPrivateKey) + seed := crypto.Sha256Hash([]byte(id)) + key := solanaApp.PrivateKeyFromSeed(seed[:]) + assets = append(assets, &solanaApp.DeployedAsset{ + AssetId: asset, + Address: key.PublicKey().String(), + Uri: uri, + Asset: na, + PrivateKey: &key, + }) + } + } + + rent, err := node.RPCGetMinimumBalanceForRentExemption(ctx, solanaApp.MintSize) + if err != nil { + panic(err) + } + tx, err := node.solana.CreateMints(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), assets, rent) + if err != nil { + return "", nil, nil, err + } + return tid, tx, assets, nil +} + +func (node *Node) CreateNonceAccount(ctx context.Context, index int) (string, string, error) { + id := fmt.Sprintf("OBSERVER:%s:MEMBERS:%v:%d", node.id, node.GetMembers(), node.conf.MTG.Genesis.Threshold) + id = common.UniqueId(id, fmt.Sprintf("computer nonce account: %d", index)) + seed := crypto.Sha256Hash(uuid.Must(uuid.FromString(id)).Bytes()) + nonce := solanaApp.PrivateKeyFromSeed(seed[:]) + + rent, err := node.RPCGetMinimumBalanceForRentExemption(ctx, solanaApp.NonceAccountSize) + if err != nil { + panic(err) + } + tx, err := node.solana.CreateNonceAccount(ctx, node.conf.SolanaKey, nonce.String(), rent) + if err != nil { + return "", "", err + } + _, err = node.SendTransactionUtilConfirm(ctx, tx, nil) + if err != nil { + return "", "", err + } + for { + hash, err := node.solana.GetNonceAccountHash(ctx, nonce.PublicKey()) + if err != nil { + return "", "", err + } + if hash == nil { + time.Sleep(5 * time.Second) + continue + } + return nonce.PublicKey().String(), hash.String(), nil + } +} + +func (node *Node) CreatePrepareTransaction(ctx context.Context, call *store.SystemCall, nonce *store.NonceAccount, fee *store.UserOutput) (*solana.Transaction, error) { + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + if err != nil { + return nil, fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", call.RequestId, err) + } + if len(os) == 0 && fee == nil { + return nil, nil + } + + mtg := node.getMTGAddress(ctx) + user, err := node.store.ReadUser(ctx, call.UserIdFromPublicPath()) + if err != nil || user == nil { + return nil, fmt.Errorf("store.ReadUser(%s) => %s %v", call.UserIdFromPublicPath(), user, err) + } + destination := solana.MustPublicKeyFromBase58(user.ChainAddress) + assets := node.GetSystemCallRelatedAsset(ctx, os) + var transfers []*solanaApp.TokenTransfer + for _, asset := range assets { + amount := asset.Amount.Mul(decimal.New(1, int32(asset.Decimal))) + mint := solana.MustPublicKeyFromBase58(asset.Address) + transfers = append(transfers, &solanaApp.TokenTransfer{ + SolanaAsset: asset.Solana, + AssetId: asset.AssetId, + ChainId: asset.ChainId, + Mint: mint, + Destination: destination, + Amount: amount.BigInt().Uint64(), + Decimals: uint8(asset.Decimal), + Fee: asset.Fee, + }) + } + if fee != nil { + amount := decimal.RequireFromString(fee.Amount).Mul(decimal.New(1, int32(fee.Asset.Precision))) + mint := solana.MustPublicKeyFromBase58(fee.Asset.AssetKey) + transfers = append(transfers, &solanaApp.TokenTransfer{ + SolanaAsset: true, + AssetId: fee.AssetId, + ChainId: fee.ChainId, + Mint: mint, + Destination: destination, + Amount: amount.BigInt().Uint64(), + Decimals: uint8(fee.Asset.Precision), + Fee: true, + }) + } + if len(transfers) == 0 { + return nil, nil + } + + node.sortSolanaTransfers(transfers) + return node.solana.TransferOrMintTokens(ctx, node.SolanaPayer(), mtg, nonce.Account(), transfers) +} + +func (node *Node) CreatePostProcessTransaction(ctx context.Context, call *store.SystemCall, nonce *store.NonceAccount, tx *solana.Transaction, meta *rpc.TransactionMeta) *solana.Transaction { + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + if err != nil { + panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", call.RequestId, err)) + } + + ras := node.GetSystemCallRelatedAsset(ctx, os) + assets := make(map[string]*ReferencedTxAsset) + for _, a := range ras { + if assets[a.Address] != nil { + assets[a.Address].Amount = assets[a.Address].Amount.Add(a.Amount) + continue + } + assets[a.Address] = a + } + + user := node.getUserSolanaPublicKeyFromCall(ctx, call) + if tx != nil && meta != nil { + changes := node.buildUserBalanceChangesFromMeta(ctx, tx, meta, user) + for address, change := range changes { + old := assets[address] + if old != nil { + assets[address].Amount = assets[address].Amount.Add(change.Amount) + continue + } + + if !change.Amount.IsPositive() { + switch address { + case solanaApp.SolanaEmptyAddress, solanaApp.WrappedSolanaAddress: + continue + } + panic(fmt.Errorf("invalid change for system call: %s %s %v", tx.Signatures[0].String(), call.RequestId, change)) + } + da, err := node.store.ReadDeployedAssetByAddress(ctx, address) + if err != nil { + panic(fmt.Errorf("store.ReadDeployedAssetByAddress(%s) => %v %v", address, da, err)) + } + isSolAsset := true + assetId := ethereum.BuildChainAssetId(solanaApp.SolanaChainBase, address) + if address == solanaApp.SolanaEmptyAddress { + assetId = solanaApp.SolanaChainBase + } + chainId := solanaApp.SolanaChainBase + if da != nil { + isSolAsset = false + assetId = da.AssetId + chainId = da.ChainId + } + assets[address] = &ReferencedTxAsset{ + Solana: isSolAsset, + Address: address, + Decimal: int(change.Decimals), + Amount: change.Amount, + AssetId: assetId, + ChainId: chainId, + } + } + } + + rent, err := node.RPCGetMinimumBalanceForRentExemption(ctx, solanaApp.NormalAccountSize) + if err != nil { + panic(err) + } + var transfers []*solanaApp.TokenTransfer + for _, asset := range assets { + dust := decimal.RequireFromString("0.00000001") + if asset.Amount.Cmp(dust) < 0 { + continue + } + amount := asset.Amount.Mul(decimal.New(1, int32(asset.Decimal))) + if !amount.BigInt().IsUint64() { + continue + } + if asset.AssetId == solanaApp.SolanaChainBase { + limit := decimal.NewFromUint64(rent) + if amount.Cmp(limit) < 1 { + logger.Printf("skip SOL transfer in post-process: %v", asset) + continue + } + } + mint := solana.MustPublicKeyFromBase58(asset.Address) + transfers = append(transfers, &solanaApp.TokenTransfer{ + SolanaAsset: asset.Solana, + AssetId: asset.AssetId, + ChainId: asset.ChainId, + Mint: mint, + Destination: solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry), + Amount: amount.BigInt().Uint64(), + Decimals: uint8(asset.Decimal), + }) + } + if len(transfers) == 0 { + return nil + } + + node.sortSolanaTransfers(transfers) + err = node.checkMintsUntilSufficient(ctx, transfers) + if err != nil { + panic(err) + } + + tx, err = node.solana.TransferOrBurnTokens(ctx, node.SolanaPayer(), user, nonce.Account(), transfers) + if err != nil { + panic(err) + } + return tx +} + +type BalanceChange struct { + Owner solana.PublicKey + Amount decimal.Decimal + Decimals uint8 +} + +// processTransactionWithAddressLookups resolves the address lookups in the transaction. +func (node *Node) processTransactionWithAddressLookups(ctx context.Context, txx *solana.Transaction) error { + if txx.Message.IsResolved() { + return nil + } + + if !txx.Message.IsVersioned() { + // tx is not versioned, ignore + return nil + } + + tblKeys := txx.Message.GetAddressTableLookups().GetTableIDs() + if len(tblKeys) == 0 { + return nil + } + numLookups := txx.Message.GetAddressTableLookups().NumLookups() + if numLookups == 0 { + return nil + } + + resolutions := make(map[solana.PublicKey]solana.PublicKeySlice) + infos, err := node.RPCGetMultipleAccounts(ctx, tblKeys) + if err != nil { + return fmt.Errorf("node.RPCGetMultipleAccounts() => %v", err) + } + for index, info := range infos.Value { + if info == nil { + return fmt.Errorf("get account info: not found") + } + key := tblKeys[index] + tableContent, err := lookup.DecodeAddressLookupTableState(info.Data.GetBinary()) + if err != nil { + return fmt.Errorf("decode address lookup table state: %s %w", key, err) + } + + resolutions[key] = tableContent.Addresses + } + + if err := txx.Message.SetAddressTables(resolutions); err != nil { + return fmt.Errorf("set address tables: %w", err) + } + + if err := txx.Message.ResolveLookups(); err != nil { + return fmt.Errorf("resolve lookups: %w ", err) + } + + return nil +} + +func (node *Node) buildUserBalanceChangesFromMeta(ctx context.Context, tx *solana.Transaction, meta *rpc.TransactionMeta, user solana.PublicKey) map[string]*BalanceChange { + err := node.processTransactionWithAddressLookups(ctx, tx) + if err != nil { + panic(err) + } + as, err := tx.AccountMetaList() + if err != nil { + panic(err) + } + + changes := make(map[string]*BalanceChange) + for index, account := range as { + if !account.PublicKey.Equals(user) { + continue + } + change := decimal.NewFromUint64(meta.PostBalances[index]).Sub(decimal.NewFromUint64(meta.PreBalances[index])) + change = change.Div(decimal.New(1, 9)) + changes[solanaApp.SolanaEmptyAddress] = &BalanceChange{ + Amount: change, + Decimals: 9, + } + } + + preMap := buildBalanceMap(meta.PreTokenBalances, &user) + postMap := buildBalanceMap(meta.PostTokenBalances, &user) + for address, tb := range preMap { + post := postMap[address] + if post == nil { + changes[address] = &BalanceChange{ + Amount: tb.Amount.Neg(), + Decimals: tb.Decimals, + } + continue + } + amount := post.Amount.Sub(tb.Amount) + changes[address] = &BalanceChange{ + Amount: amount, + Decimals: tb.Decimals, + } + } + for address, c := range postMap { + if changes[address] != nil { + continue + } + changes[address] = c + } + return changes +} + +func (node *Node) checkInternalAccountsFromMeta(ctx context.Context, tx *solana.Transaction, meta *rpc.TransactionMeta) bool { + var accounts []string + + al := len(tx.Message.AccountKeys) + for index, post := range meta.PostBalances { + if index >= al { + continue + } + increase := decimal.NewFromUint64(post).Sub(decimal.NewFromUint64(meta.PreBalances[index])) + if !increase.IsPositive() { + continue + } + accounts = append(accounts, tx.Message.AccountKeys[index].String()) + } + + for _, acc := range meta.LoadedAddresses.Writable { + accounts = append(accounts, acc.String()) + } + + changes := make(map[string]*BalanceChange) + preMap := buildBalanceMap(meta.PreTokenBalances, nil) + postMap := buildBalanceMap(meta.PostTokenBalances, nil) + for key, tb := range preMap { + post := postMap[key] + if post == nil { + changes[key] = &BalanceChange{ + Owner: tb.Owner, + Amount: tb.Amount.Neg(), + Decimals: tb.Decimals, + } + continue + } + amount := post.Amount.Sub(tb.Amount) + changes[key] = &BalanceChange{ + Owner: tb.Owner, + Amount: amount, + Decimals: tb.Decimals, + } + } + for key, c := range postMap { + if changes[key] != nil { + continue + } + changes[key] = c + } + for _, c := range changes { + if !c.Amount.IsPositive() || slices.Contains(accounts, c.Owner.String()) { + continue + } + accounts = append(accounts, c.Owner.String()) + } + + c, err := node.store.CheckInternalAccounts(ctx, accounts) + if err != nil { + panic(err) + } + + return c > 0 +} + +func buildBalanceMap(balances []rpc.TokenBalance, owner *solana.PublicKey) map[string]*BalanceChange { + bm := make(map[string]*BalanceChange) + for _, tb := range balances { + if owner != nil && !tb.Owner.Equals(*owner) { + continue + } + key := tb.Mint.String() + if owner == nil { + key = fmt.Sprintf("%s:%s", tb.Owner.String(), tb.Mint.String()) + } + amount := decimal.RequireFromString(tb.UiTokenAmount.UiAmountString) + bm[key] = &BalanceChange{ + Owner: *tb.Owner, + Amount: amount, + Decimals: tb.UiTokenAmount.Decimals, + } + } + return bm +} + +func (node *Node) VerifySubSystemCall(ctx context.Context, tx *solana.Transaction, groupDepositEntry, user solana.PublicKey) error { + for index, ix := range tx.Message.Instructions { + accounts, err := ix.ResolveInstructionAccounts(&tx.Message) + if err != nil { + panic(err) + } + + if index == 0 { + _, err := solanaApp.DecodeNonceAdvance(accounts, ix.Data) + if err != nil { + return fmt.Errorf("invalid nonce advance instruction: %v", err) + } + continue + } + + programKey, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil { + panic(err) + } + switch programKey { + case system.ProgramID: + if _, ok := solanaApp.DecodeCreateAccount(accounts, ix.Data); ok { + continue + } + if transfer, ok := solanaApp.DecodeSystemTransfer(accounts, ix.Data); ok { + recipient := transfer.GetRecipientAccount().PublicKey + if !recipient.Equals(groupDepositEntry) && !recipient.Equals(user) { + return fmt.Errorf("invalid system transfer recipient: %s", recipient.String()) + } + continue + } + return fmt.Errorf("invalid system program instruction: %d", index) + case solana.TokenProgramID, solana.Token2022ProgramID: + if mint, ok := solanaApp.DecodeTokenMintTo(accounts, ix.Data); ok { + to := mint.GetDestinationAccount().PublicKey + token := mint.GetMintAccount().PublicKey + ata := solanaApp.FindAssociatedTokenAddress(user, token, programKey) + if !to.Equals(ata) { + return fmt.Errorf("invalid mint to destination: %s", to.String()) + } + continue + } + if transfer, ok := solanaApp.DecodeTokenTransferChecked(accounts, ix.Data); ok { + recipient := transfer.GetDestinationAccount().PublicKey + token := transfer.GetMintAccount().PublicKey + entryAta := solanaApp.FindAssociatedTokenAddress(groupDepositEntry, token, programKey) + userAta := solanaApp.FindAssociatedTokenAddress(user, token, programKey) + if !recipient.Equals(entryAta) && !recipient.Equals(userAta) { + return fmt.Errorf("invalid token transfer recipient: %s", recipient.String()) + } + continue + } + if burn, ok := solanaApp.DecodeTokenBurn(accounts, ix.Data); ok { + owner := burn.GetOwnerAccount().PublicKey + if !owner.Equals(user) { + return fmt.Errorf("invalid token burn owners: %s", owner.String()) + } + continue + } + return fmt.Errorf("invalid token program instruction: %d", index) + case tokenAta.ProgramID, solana.ComputeBudget: + default: + return fmt.Errorf("invalid program key: %s", programKey.String()) + } + } + return nil +} + +func (node *Node) parseSolanaBlockBalanceChanges(ctx context.Context, transfers []*solanaApp.Transfer) (map[string]*big.Int, error) { + mtgAddress := node.getMTGAddress(ctx).String() + + changes := make(map[string]*big.Int) + for _, t := range transfers { + if t.Receiver == solanaApp.SolanaEmptyAddress || + t.Sender == node.SolanaPayer().String() || + t.Sender == mtgAddress || + t.Receiver == mtgAddress { + continue + } + + user, err := node.store.ReadUserByChainAddress(ctx, t.Receiver) + logger.Verbosef("store.ReadUserByAddress(%s) => %v %v", t.Receiver, user, err) + if err != nil { + return nil, err + } else if user == nil { + continue + } + token, err := node.store.ReadDeployedAssetByAddress(ctx, t.TokenAddress) + if err != nil { + return nil, err + } else if token != nil { + continue + } + + key := fmt.Sprintf("%s:%s", t.Receiver, t.TokenAddress) + total := changes[key] + if total != nil { + changes[key] = new(big.Int).Add(total, t.Value) + } else { + changes[key] = t.Value + } + } + return changes, nil +} + +func (node *Node) getUserSolanaPublicKeyFromCall(ctx context.Context, c *store.SystemCall) solana.PublicKey { + data := common.DecodeHexOrPanic(c.Public) + if len(data) != 16 { + panic(fmt.Errorf("invalid public of system call: %s %s", c.RequestId, c.Public)) + } + fp, path := hex.EncodeToString(data[:8]), data[8:] + _, share, err := node.store.ReadKeyByFingerprint(ctx, fp) + if err != nil { + panic(err) + } + pub, _ := node.deriveByPath(share, path) + return solana.PublicKeyFromBytes(pub) +} + +func (node *Node) SolanaPayer() solana.PublicKey { + return solana.MustPrivateKeyFromBase58(node.conf.SolanaKey).PublicKey() +} + +func (node *Node) getMTGAddress(ctx context.Context) solana.PublicKey { + key, err := node.store.ReadFirstPublicKey(ctx) + if err != nil || key == "" { + panic(fmt.Errorf("store.ReadFirstPublicKey() => %s %v", key, err)) + } + return solana.PublicKeyFromBytes(common.DecodeHexOrPanic(key)) +} + +func (node *Node) solanaDepositEntry() solana.PublicKey { + return solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry) +} + +func (node *Node) sortSolanaTransfers(transfers []*solanaApp.TokenTransfer) { + sort.Slice(transfers, func(i, j int) bool { + if transfers[i].AssetId != transfers[j].AssetId { + return transfers[i].AssetId > transfers[j].AssetId + } + return transfers[i].Amount > transfers[j].Amount + }) +} diff --git a/computer/solana_test.go b/computer/solana_test.go new file mode 100644 index 00000000..e79aaba5 --- /dev/null +++ b/computer/solana_test.go @@ -0,0 +1,153 @@ +package computer + +import ( + "context" + "database/sql" + "encoding/hex" + "os" + "testing" + "time" + + "github.com/MixinNetwork/mixin/crypto" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/computer/store" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +const ( + testRpcEndpoint = "https://api.mainnet-beta.solana.com" + testNonceAccountAddress = "FLq1XqAbaFjib59q6mRDRFEzoQnTShWu1Vis7q57HKtd" + testNonceAccountHash = "8j6J9Z8GdbkY1VsJKuKk799nGfkNchMGZ9LY2bdvtYrZ" + + testPayerPrivKey = "56HtVW5YQ9Xi8MTeQFAWdSuzV17mrDAr1AUCYzTdx36VLvsodA89eSuZd6axrufzo4tyoUNdgjDpm4fnLJLRcXmF" + testUserNonceAccountPrivKey = "5mCExzNoFSY8UwVbGYPiVtmfeWtqoNeprRymq4wU7yZwWxVCrpXoX7F2KSEFrbVEPRSUjejAeNBbFYMhC3iiu4F5" + testUserNonceAccountHash = "FrqtK1eTYLJtR6mGNaBWF6qyfpjTqk1DJaAQdAm31Xc1" +) + +func TestSolana(t *testing.T) { + require := require.New(t) + ctx, nodes, _ := testPrepare(require) + testFROSTPrepareKeys(ctx, require, nodes, testFROSTKeys1, "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b") + testFROSTPrepareKeys(ctx, require, nodes, testFROSTKeys2, "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295") + + node := nodes[0] + count, err := node.store.CountKeys(ctx) + require.Nil(err) + require.Equal(2, count) + key, err := node.store.ReadLatestPublicKey(ctx) + require.Nil(err) + require.Equal("4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295", key) + payer, err := solana.PrivateKeyFromBase58(testPayerPrivKey) + require.Nil(err) + require.Equal("ErFBVPGYmi8Vjuf1jAfmZLzyFHLnF9c1MNhfcEQGdgMb", payer.PublicKey().String()) + addr := solana.PublicKeyFromBytes(common.DecodeHexOrPanic(key)) + require.Equal("5YLSixqjK2m8ECirGaco8tHSn2Uc4aY7cLPoMSMptsgG", addr.String()) + + nonceAccount := solana.MustPrivateKeyFromBase58(testUserNonceAccountPrivKey) + require.Equal("DaJw3pa9rxr25AT1HnQnmPvwS4JbnwNvQbNLm8PJRhqV", nonceAccount.PublicKey().String()) + nonceHash := solana.MustHashFromBase58(testUserNonceAccountHash) + + amount, _ := decimal.NewFromString("0.001") + + b := solana.NewTransactionBuilder() + b.SetRecentBlockHash(nonceHash) + b.SetFeePayer(payer.PublicKey()) + b.AddInstruction(system.NewAdvanceNonceAccountInstruction( + nonceAccount.PublicKey(), + solana.SysVarRecentBlockHashesPubkey, + payer.PublicKey(), + ).Build()) + b.AddInstruction(system.NewTransferInstruction( + decimal.New(1, 9).Mul(amount).BigInt().Uint64(), + addr, + addr, + ).Build()) + tx, err := b.Build() + require.Nil(err) + _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) + require.Nil(err) + + testFROSTSign(ctx, require, nodes, nonceAccount.PublicKey().String(), key, tx) +} + +func TestGetNonceAccountHash(t *testing.T) { + require := require.New(t) + ctx := context.Background() + rpc := testRpcEndpoint + if er := os.Getenv("SOLANARPC"); er != "" { + rpc = er + } + rpcClient := solanaApp.NewClient(rpc) + + key := solana.MustPublicKeyFromBase58(testNonceAccountAddress) + hash, err := rpcClient.GetNonceAccountHash(ctx, key) + require.Nil(err) + require.Equal(testNonceAccountHash, hash.String()) +} + +func testFROSTSign(ctx context.Context, require *require.Assertions, nodes []*Node, nonce, public string, tx *solana.Transaction) { + msg, err := tx.Message.MarshalBinary() + require.Nil(err) + require.Equal( + "02000205cdc56c8d087a301b21144b2ab5e1286b50a5d941ee02f62488db0308b943d2d64375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295bad4af79952644bd80881b3934b3e278ad2f4eeea3614e1c428350d905eac4ec06a7d517192c568ee08a845f73d29788cf035c3145b21ab344d8062ea94000000000000000000000000000000000000000000000000000000000000000000000dcc859c62859a93c7ca37d6f180d63ba1f1ccadc68373b6605c4358bd77983060204030203000404000000040201010c0200000040420f0000000000", + hex.EncodeToString(msg), + ) + + now := time.Now().UTC() + id := uuid.Must(uuid.NewV4()).String() + sid := common.UniqueId(id, now.String()) + call := &store.SystemCall{ + RequestId: id, + Superior: id, + RequestHash: "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295", + Type: store.CallTypeMain, + NonceAccount: nonce, + Public: public, + MessageHash: crypto.Sha256Hash(msg).String(), + Raw: tx.MustToBase64(), + State: common.RequestStatePending, + WithdrawalTraces: sql.NullString{Valid: true, String: ""}, + Signature: sql.NullString{Valid: false}, + RequestSignerAt: sql.NullTime{Valid: false}, + CreatedAt: now, + UpdatedAt: now, + } + pub := common.Fingerprint(call.Public) + pub = append(pub, []byte{0, 0, 0, 0, 0, 0, 0, 0}...) + for _, node := range nodes { + err := node.store.TestWriteCall(ctx, call) + require.Nil(err) + session := &store.Session{ + Id: sid, + RequestId: call.RequestId, + MixinHash: crypto.Sha256Hash([]byte(id)).String(), + MixinIndex: 0, + Index: 0, + Operation: OperationTypeSignInput, + Public: hex.EncodeToString(pub), + Extra: call.MessageHex(), + CreatedAt: now, + } + err = node.store.TestWriteSignSession(ctx, call, []*store.Session{session}) + require.Nil(err) + } + + for _, node := range nodes { + testWaitOperation(ctx, node, sid) + } + + node := nodes[0] + for { + s, err := node.store.ReadSystemCallByRequestId(ctx, call.RequestId, common.RequestStatePending) + require.Nil(err) + if s != nil && s.Signature.Valid { + return + } + time.Sleep(5 * time.Second) + } +} diff --git a/computer/store/action_result.go b/computer/store/action_result.go new file mode 100644 index 00000000..1061102d --- /dev/null +++ b/computer/store/action_result.go @@ -0,0 +1,94 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/mtg" +) + +type ActionResult struct { + ActionId string + Compaction string + Transactions []*mtg.Transaction + RequestId string + CreatedAt time.Time +} + +var requestTransactionsCols = []string{"output_id", "compaction", "transactions", "request_id", "created_at"} + +func (s *SQLite3Store) FailAction(ctx context.Context, req *Request) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.writeActionResult(ctx, tx, req.Output.OutputId, "", nil, req.Id) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) writeActionResult(ctx context.Context, tx *sql.Tx, outputId, compaction string, txs []*mtg.Transaction, requestId string) error { + vals := []any{outputId, compaction, common.Base91Encode(mtg.SerializeTransactions(txs)), requestId, time.Now().UTC()} + err := s.execOne(ctx, tx, buildInsertionSQL("action_results", requestTransactionsCols), vals...) + if err != nil { + return fmt.Errorf("INSERT action_results %v", err) + } + return nil +} + +func (s *SQLite3Store) ReadActionResult(ctx context.Context, outputId, requestId string) (*ActionResult, bool, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, false, err + } + defer common.Rollback(tx) + + row := tx.QueryRowContext(ctx, "SELECT state FROM requests where request_id=?", requestId) + var state int + err = row.Scan(&state) + if err == sql.ErrNoRows { + return nil, false, nil + } else if err != nil { + return nil, false, err + } + if state == common.RequestStateInitial { + return nil, false, nil + } + + cols := strings.Join(requestTransactionsCols, ",") + row = tx.QueryRowContext(ctx, fmt.Sprintf("SELECT %s FROM action_results where output_id=?", cols), outputId) + var ar ActionResult + var data string + err = row.Scan(&ar.ActionId, &ar.Compaction, &data, &ar.RequestId, &ar.CreatedAt) + if err == sql.ErrNoRows { + return nil, true, nil + } + if err != nil { + return nil, true, err + } + tb, err := common.Base91Decode(data) + if err != nil { + return nil, true, err + } + txs, err := mtg.DeserializeTransactions(tb) + if err != nil { + return nil, true, err + } + ar.Transactions = txs + return &ar, true, nil +} diff --git a/computer/store/caches.go b/computer/store/caches.go new file mode 100644 index 00000000..c318dbb6 --- /dev/null +++ b/computer/store/caches.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/MixinNetwork/safe/common" +) + +type Cache struct { + Key string + Value string + CreatedAt time.Time +} + +const cacheTTL = 24 * time.Hour + +func (s *SQLite3Store) ReadCache(ctx context.Context, k string) (string, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + row := s.db.QueryRowContext(ctx, "SELECT value,created_at FROM caches WHERE key=?", k) + var value string + var createdAt time.Time + err := row.Scan(&value, &createdAt) + if err == sql.ErrNoRows { + return "", nil + } else if err != nil { + return "", err + } + if createdAt.Add(cacheTTL).Before(time.Now()) { + return "", nil + } + return value, nil +} + +func (s *SQLite3Store) WriteCache(ctx context.Context, k, v string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + threshold := time.Now().Add(-cacheTTL).UTC() + _, err = tx.ExecContext(ctx, "DELETE FROM caches WHERE created_at 0 { + query += " AND state=?" + values = append(values, state) + } + + row := s.db.QueryRowContext(ctx, query, values...) + + return systemCallFromRow(row) +} + +func (s *SQLite3Store) ReadInitialSystemCallBySuperior(ctx context.Context, rid string) (*SystemCall, error) { + query := fmt.Sprintf("SELECT %s FROM system_calls WHERE superior_id=? AND state=? ORDER BY created_at ASC LIMIT 1", strings.Join(systemCallCols, ",")) + row := s.db.QueryRowContext(ctx, query, rid, common.RequestStateInitial) + + return systemCallFromRow(row) +} + +func (s *SQLite3Store) ReadSystemCallByMessage(ctx context.Context, messageHash string) (*SystemCall, error) { + query := fmt.Sprintf("SELECT %s FROM system_calls WHERE message_hash=?", strings.Join(systemCallCols, ",")) + row := s.db.QueryRowContext(ctx, query, messageHash) + + return systemCallFromRow(row) +} + +func (s *SQLite3Store) ReadSystemCallByHash(ctx context.Context, hash string) (*SystemCall, error) { + query := fmt.Sprintf("SELECT %s FROM system_calls WHERE hash=?", strings.Join(systemCallCols, ",")) + row := s.db.QueryRowContext(ctx, query, hash) + + return systemCallFromRow(row) +} + +func (s *SQLite3Store) ListUnconfirmedSystemCalls(ctx context.Context) ([]*SystemCall, error) { + query := fmt.Sprintf("SELECT %s FROM system_calls WHERE state=? AND withdrawal_traces IS NULL ORDER BY created_at ASC LIMIT 100", strings.Join(systemCallCols, ",")) + return s.listSystemCallsByQuery(ctx, query, common.RequestStateInitial) +} + +func (s *SQLite3Store) ListUnsignedCalls(ctx context.Context) ([]*SystemCall, error) { + query := fmt.Sprintf("SELECT %s FROM system_calls WHERE state!=? AND withdrawal_traces IS NOT NULL AND signature IS NULL ORDER BY created_at ASC LIMIT 100", strings.Join(systemCallCols, ",")) + return s.listSystemCallsByQuery(ctx, query, common.RequestStateFailed) +} + +func (s *SQLite3Store) ListSignedCalls(ctx context.Context) (map[string]*SystemCall, error) { + query := fmt.Sprintf("SELECT %s FROM system_calls WHERE state=? AND signature IS NOT NULL ORDER BY created_at ASC LIMIT 100", strings.Join(systemCallCols, ",")) + calls, err := s.listSystemCallsByQuery(ctx, query, common.RequestStatePending) + if err != nil { + return nil, err + } + + callMap := make(map[string]*SystemCall) + for _, call := range calls { + callMap[call.RequestId] = call + } + return callMap, nil +} + +func (s *SQLite3Store) listSystemCallsByQuery(ctx context.Context, query string, params ...any) ([]*SystemCall, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + rows, err := s.db.QueryContext(ctx, query, params...) + if err != nil { + return nil, err + } + defer rows.Close() + + var calls []*SystemCall + for rows.Next() { + call, err := systemCallFromRow(rows) + if err != nil { + return nil, err + } + calls = append(calls, call) + } + return calls, nil +} + +func (s *SQLite3Store) CountUserSystemCallByState(ctx context.Context, state byte) (int, error) { + query := "SELECT COUNT(*) FROM system_calls where call_type=? AND state=?" + row := s.db.QueryRowContext(ctx, query, CallTypeMain, state) + + var count int + err := row.Scan(&count) + if err == sql.ErrNoRows { + return 0, nil + } + return count, err +} + +func (s *SQLite3Store) CheckUnfinishedSubCalls(ctx context.Context, call *SystemCall) (bool, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer common.Rollback(tx) + + return s.checkExistence(ctx, tx, "SELECT id FROM system_calls WHERE call_type=? AND state=? AND superior_id=?", CallTypePrepare, common.RequestStatePending, call.RequestId) +} + +func (s *SQLite3Store) writeSystemCall(ctx context.Context, tx *sql.Tx, call *SystemCall) error { + vals := []any{call.RequestId, call.Superior, call.RequestHash, call.Type, call.NonceAccount, call.Public, call.SkipPostProcess, call.MessageHash, call.Raw, call.State, call.WithdrawalTraces, call.Signature, call.RequestSignerAt, call.Hash, call.CreatedAt, call.UpdatedAt} + err := s.execOne(ctx, tx, buildInsertionSQL("system_calls", systemCallCols), vals...) + if err != nil { + return fmt.Errorf("INSERT system_calls %v", err) + } + return nil +} diff --git a/computer/store/deployed_asset.go b/computer/store/deployed_asset.go new file mode 100644 index 00000000..35194b47 --- /dev/null +++ b/computer/store/deployed_asset.go @@ -0,0 +1,93 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/MixinNetwork/safe/apps/solana" + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" +) + +var deployedAssetCols = []string{"asset_id", "chain_id", "address", "decimals", "created_at"} + +func deployedAssetFromRow(row Row) (*solana.DeployedAsset, error) { + var a solana.DeployedAsset + err := row.Scan(&a.AssetId, &a.ChainId, &a.Address, &a.Decimals, &a.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &a, err +} + +func (s *SQLite3Store) WriteDeployedAssetsWithRequest(ctx context.Context, req *Request, assets []*solanaApp.DeployedAsset) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + for _, asset := range assets { + existed, err := s.checkExistence(ctx, tx, "SELECT address FROM deployed_assets WHERE asset_id=?", asset.AssetId) + if err != nil { + return err + } + if existed { + continue + } + + vals := []any{asset.AssetId, asset.ChainId, asset.Address, asset.Decimals, req.CreatedAt} + err = s.execOne(ctx, tx, buildInsertionSQL("deployed_assets", deployedAssetCols), vals...) + if err != nil { + return fmt.Errorf("INSERT deployed_assets %v", err) + } + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadDeployedAsset(ctx context.Context, id string) (*solana.DeployedAsset, error) { + query := fmt.Sprintf("SELECT %s FROM deployed_assets WHERE asset_id=?", strings.Join(deployedAssetCols, ",")) + row := s.db.QueryRowContext(ctx, query, id) + + return deployedAssetFromRow(row) +} + +func (s *SQLite3Store) ReadDeployedAssetByAddress(ctx context.Context, address string) (*solana.DeployedAsset, error) { + query := fmt.Sprintf("SELECT %s FROM deployed_assets WHERE address=?", strings.Join(deployedAssetCols, ",")) + row := s.db.QueryRowContext(ctx, query, address) + + return deployedAssetFromRow(row) +} + +func (s *SQLite3Store) ListDeployedAssets(ctx context.Context) ([]*solana.DeployedAsset, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + query := fmt.Sprintf("SELECT %s FROM deployed_assets LIMIT 500", strings.Join(deployedAssetCols, ",")) + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var as []*solana.DeployedAsset + for rows.Next() { + asset, err := deployedAssetFromRow(rows) + if err != nil { + return nil, err + } + as = append(as, asset) + } + return as, nil +} diff --git a/computer/store/external_asset.go b/computer/store/external_asset.go new file mode 100644 index 00000000..06806767 --- /dev/null +++ b/computer/store/external_asset.go @@ -0,0 +1,162 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" +) + +type ExternalAsset struct { + AssetId string + Uri sql.NullString + IconUrl sql.NullString + CreatedAt time.Time + DeployedHash sql.NullString +} + +var externalAssetCols = []string{"asset_id", "uri", "icon_url", "deployed_hash", "created_at"} + +func externalAssetFromRow(row Row) (*ExternalAsset, error) { + var a ExternalAsset + err := row.Scan(&a.AssetId, &a.Uri, &a.IconUrl, &a.DeployedHash, &a.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &a, err +} + +func (s *SQLite3Store) WriteExternalAssets(ctx context.Context, assets []*ExternalAsset) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + for _, asset := range assets { + vals := []any{asset.AssetId, nil, nil, nil, asset.CreatedAt} + err = s.execOne(ctx, tx, buildInsertionSQL("external_assets", externalAssetCols), vals...) + if err != nil { + return fmt.Errorf("INSERT external_assets %v", err) + } + } + + return tx.Commit() +} + +func (s *SQLite3Store) UpdateExternalAssetUri(ctx context.Context, id, uri string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "UPDATE external_assets SET uri=? WHERE asset_id=? AND uri IS NULL" + _, err = tx.ExecContext(ctx, query, uri, id) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE external_assets %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) UpdateExternalAssetIconUrl(ctx context.Context, id, uri string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "UPDATE external_assets SET icon_url=? WHERE asset_id=? AND icon_url IS NULL" + _, err = tx.ExecContext(ctx, query, uri, id) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE external_assets %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) MarkExternalAssetDeployed(ctx context.Context, assets []*solanaApp.DeployedAsset, hash string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + for _, a := range assets { + query := "UPDATE external_assets SET deployed_hash=? WHERE asset_id=? AND deployed_hash IS NULL" + err = s.execOne(ctx, tx, query, hash, a.AssetId) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE external_assets %v", err) + } + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadExternalAsset(ctx context.Context, id string) (*ExternalAsset, error) { + query := fmt.Sprintf("SELECT %s FROM external_assets WHERE asset_id=?", strings.Join(externalAssetCols, ",")) + row := s.db.QueryRowContext(ctx, query, id) + + return externalAssetFromRow(row) +} + +func (s *SQLite3Store) ListUndeployedAssets(ctx context.Context) ([]*ExternalAsset, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + query := fmt.Sprintf("SELECT %s FROM external_assets WHERE deployed_hash IS NULL LIMIT 500", strings.Join(externalAssetCols, ",")) + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var as []*ExternalAsset + for rows.Next() { + asset, err := externalAssetFromRow(rows) + if err != nil { + return nil, err + } + as = append(as, asset) + } + return as, nil +} + +func (s *SQLite3Store) ListAssetIconUrls(ctx context.Context) (map[string]string, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + query := fmt.Sprintf("SELECT %s FROM external_assets WHERE icon_url IS NOT NULL AND deployed_hash IS NOT NULL LIMIT 500", strings.Join(externalAssetCols, ",")) + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + um := make(map[string]string) + for rows.Next() { + asset, err := externalAssetFromRow(rows) + if err != nil { + return nil, err + } + um[asset.AssetId] = asset.IconUrl.String + } + return um, nil +} diff --git a/computer/store/failed_call.go b/computer/store/failed_call.go new file mode 100644 index 00000000..55359aac --- /dev/null +++ b/computer/store/failed_call.go @@ -0,0 +1,49 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/MixinNetwork/safe/common" +) + +func (s *SQLite3Store) WriteFailedCallIfNotExist(ctx context.Context, call *SystemCall, reason string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT reason FROM failed_calls WHERE call_id=?", call.RequestId) + if err != nil || existed { + return err + } + + vals := []any{call.RequestId, reason, time.Now()} + err = s.execOne(ctx, tx, buildInsertionSQL("failed_calls", []string{"call_id", "reason", "created_at"}), vals...) + if err != nil { + return fmt.Errorf("INSERT failed_calls %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadFailReason(ctx context.Context, id string) (string, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + row := s.db.QueryRowContext(ctx, "SELECT reason FROM failed_calls WHERE call_id=?", id) + var reason string + err := row.Scan(&reason) + if err == sql.ErrNoRows { + return "", nil + } else if err != nil { + return "", err + } + return reason, nil +} diff --git a/computer/store/fee.go b/computer/store/fee.go new file mode 100644 index 00000000..6128a711 --- /dev/null +++ b/computer/store/fee.go @@ -0,0 +1,73 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/safe/common" + "github.com/shopspring/decimal" +) + +type FeeInfo struct { + Id string + Ratio string + CreatedAt time.Time +} + +var feeCols = []string{"fee_id", "ratio", "created_at"} + +func feeFromRow(row Row) (*FeeInfo, error) { + var f FeeInfo + err := row.Scan(&f.Id, &f.Ratio, &f.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &f, err +} + +func (s *SQLite3Store) WriteFeeInfo(ctx context.Context, id string, ratio decimal.Decimal) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT fee_id FROM fees WHERE fee_id=?", id) + if err != nil || existed { + return err + } + + vals := []any{id, ratio.String(), time.Now().UTC()} + err = s.execOne(ctx, tx, buildInsertionSQL("fees", feeCols), vals...) + if err != nil { + return fmt.Errorf("INSERT fees %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadFeeInfoById(ctx context.Context, id string) (*FeeInfo, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + query := fmt.Sprintf("SELECT %s FROM fees where fee_id=?", strings.Join(feeCols, ",")) + row := s.db.QueryRowContext(ctx, query, id) + + return feeFromRow(row) +} + +func (s *SQLite3Store) ReadLatestFeeInfo(ctx context.Context) (*FeeInfo, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + query := fmt.Sprintf("SELECT %s FROM fees ORDER BY created_at DESC LIMIT 1", strings.Join(feeCols, ",")) + row := s.db.QueryRowContext(ctx, query) + + return feeFromRow(row) +} diff --git a/computer/store/key.go b/computer/store/key.go new file mode 100644 index 00000000..b5a686b7 --- /dev/null +++ b/computer/store/key.go @@ -0,0 +1,155 @@ +package store + +import ( + "context" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/MixinNetwork/safe/common" +) + +const ( + UserInitializeTimeKey = "user-initialize-time" + KeygenRequestTimeKey = "keygen-request-time" + NonceAccountRequestTimeKey = "nonce-request-time" + WithdrawalConfirmRequestTimeKey = "withdrawal-request-time" + SolanaScanHeightKey = "solana-scan-height" +) + +type KeygenResult struct { + Public []byte + Share []byte + SSID []byte +} + +type Key struct { + Public string + Fingerprint string + Share string + SessionId string + CreatedAt time.Time + UpdatedAt time.Time + ConfirmedAt sql.NullTime + BackedUpAt sql.NullTime +} + +func (s *SQLite3Store) WriteKeyIfNotExists(ctx context.Context, session *Session, public string, conf []byte, saved bool) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT public FROM keys WHERE public=?", public) + if err != nil || existed { + return err + } + + timestamp := time.Now().UTC() + share := common.Base91Encode(conf) + fingerprint := hex.EncodeToString(common.Fingerprint(public)) + cols := []string{"public", "fingerprint", "share", "session_id", "created_at", "updated_at"} + values := []any{public, fingerprint, share, session.Id, session.CreatedAt, timestamp} + if saved { + cols = append(cols, "backed_up_at") + values = append(values, timestamp) + } + + err = s.execOne(ctx, tx, buildInsertionSQL("keys", cols), values...) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT keys %v", err) + } + + err = s.execOne(ctx, tx, "UPDATE sessions SET public=?, state=?, updated_at=? WHERE session_id=? AND created_at=updated_at AND state=?", + public, common.RequestStatePending, timestamp, session.Id, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) MarkKeyConfirmedWithRequest(ctx context.Context, req *Request, public string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "UPDATE keys SET confirmed_at=?, updated_at=? WHERE public=? AND confirmed_at IS NULL" + err = s.execOne(ctx, tx, query, req.CreatedAt, req.CreatedAt, public) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE keys %v", err) + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadKeyByFingerprint(ctx context.Context, sum string) (string, []byte, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + var public, share string + row := s.db.QueryRowContext(ctx, "SELECT public, share FROM keys WHERE fingerprint=?", sum) + err := row.Scan(&public, &share) + if err == sql.ErrNoRows { + return "", nil, nil + } else if err != nil { + return "", nil, err + } + conf, err := common.Base91Decode(share) + return public, conf, err +} + +// the mpc key with default path +// used as address on solana chain +func (s *SQLite3Store) ReadFirstPublicKey(ctx context.Context) (string, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + var public string + row := s.db.QueryRowContext(ctx, "SELECT public FROM keys WHERE confirmed_at IS NOT NULL ORDER BY confirmed_at ASC LIMIT 1") + err := row.Scan(&public) + if err != nil { + return "", err + } + return public, err +} + +func (s *SQLite3Store) ReadLatestPublicKey(ctx context.Context) (string, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + var public string + row := s.db.QueryRowContext(ctx, "SELECT public FROM keys WHERE confirmed_at IS NOT NULL ORDER BY confirmed_at DESC LIMIT 1") + err := row.Scan(&public) + if err != nil { + return "", err + } + return public, err +} + +func (s *SQLite3Store) CountKeys(ctx context.Context) (int, error) { + query := "SELECT COUNT(*) FROM keys WHERE confirmed_at IS NOT NULL" + row := s.db.QueryRowContext(ctx, query) + + var count int + err := row.Scan(&count) + if err == sql.ErrNoRows { + return 0, nil + } + return count, err +} diff --git a/computer/store/network.go b/computer/store/network.go new file mode 100644 index 00000000..0aa28f70 --- /dev/null +++ b/computer/store/network.go @@ -0,0 +1,66 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/safe/common" + "github.com/shopspring/decimal" +) + +type OperationParams struct { + RequestId string + OperationPriceAsset string + OperationPriceAmount decimal.Decimal + CreatedAt time.Time +} + +var paramsCols = []string{"request_id", "price_asset", "price_amount", "created_at"} + +func (s *SQLite3Store) ReadLatestOperationParams(ctx context.Context, offset time.Time) (*OperationParams, error) { + query := fmt.Sprintf("SELECT %s FROM operation_params WHERE created_at<=? ORDER BY created_at DESC, request_id DESC LIMIT 1", strings.Join(paramsCols, ",")) + row := s.db.QueryRowContext(ctx, query, offset) + + var p OperationParams + var price string + err := row.Scan(&p.RequestId, &p.OperationPriceAsset, &price, &p.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, err + } + p.OperationPriceAmount = decimal.RequireFromString(price) + return &p, nil +} + +func (s *SQLite3Store) WriteOperationParamsFromRequest(ctx context.Context, params *OperationParams, req *Request) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT request_id FROM requests WHERE request_id=? AND state=?", params.RequestId, common.RequestStateDone) + if err != nil || existed { + return err + } + + amount := params.OperationPriceAmount.String() + vals := []any{params.RequestId, params.OperationPriceAsset, amount, params.CreatedAt} + err = s.execOne(ctx, tx, buildInsertionSQL("operation_params", paramsCols), vals...) + if err != nil { + return fmt.Errorf("INSERT operation_params %v", err) + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + return tx.Commit() +} diff --git a/computer/store/nonce.go b/computer/store/nonce.go new file mode 100644 index 00000000..716a435a --- /dev/null +++ b/computer/store/nonce.go @@ -0,0 +1,227 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + solanaApp "github.com/MixinNetwork/safe/apps/solana" + "github.com/MixinNetwork/safe/common" + "github.com/gagliardetto/solana-go" +) + +type NonceAccount struct { + Address string + Hash string + Mix sql.NullString + CallId sql.NullString + UpdatedBy sql.NullString + CreatedAt time.Time + UpdatedAt time.Time +} + +var nonceAccountCols = []string{"address", "hash", "mix", "call_id", "updated_by", "created_at", "updated_at"} + +func nonceAccountFromRow(row Row) (*NonceAccount, error) { + var a NonceAccount + err := row.Scan(&a.Address, &a.Hash, &a.Mix, &a.CallId, &a.UpdatedBy, &a.CreatedAt, &a.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &a, err +} + +func (a *NonceAccount) Account() solanaApp.NonceAccount { + return solanaApp.NonceAccount{ + Address: solana.MustPublicKeyFromBase58(a.Address), + Hash: solana.MustHashFromBase58(a.Hash), + } +} + +func (a *NonceAccount) LockedByUserOnly() bool { + return a.Mix.Valid && !a.CallId.Valid +} + +func (a *NonceAccount) Valid(cid string) bool { + return a.Mix.Valid && (!a.CallId.Valid || a.CallId.String == cid) +} + +func (a *NonceAccount) Expired() bool { + return a.UpdatedAt.Add(20 * time.Minute).Before(time.Now()) +} + +func (s *SQLite3Store) WriteNonceAccount(ctx context.Context, address, hash string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + now := time.Now().UTC() + vals := []any{address, hash, nil, nil, nil, now, now} + err = s.execOne(ctx, tx, buildInsertionSQL("nonce_accounts", nonceAccountCols), vals...) + if err != nil { + return fmt.Errorf("INSERT nonce_accounts %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) UpdateNonceAccount(ctx context.Context, address, hash, call string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + now := time.Now().UTC() + err = s.execOne(ctx, tx, "UPDATE nonce_accounts SET hash=?, mix=?, call_id=?, updated_by=?, updated_at=? WHERE address=?", + hash, nil, nil, call, now, address) + if err != nil { + return fmt.Errorf("UPDATE nonce_accounts %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) LockNonceAccountWithMix(ctx context.Context, address, mix string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE nonce_accounts SET mix=?, updated_at=? WHERE address=? AND mix IS NULL AND call_id IS NULL", + mix, time.Now().UTC(), address) + if err != nil { + return fmt.Errorf("UPDATE nonce_accounts %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) OccupyNonceAccountByCall(ctx context.Context, address, call string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE nonce_accounts SET call_id=?, updated_at=? WHERE address=? AND call_id IS NULL", + call, time.Now().UTC(), address) + if err != nil { + return fmt.Errorf("UPDATE nonce_accounts %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReleaseLockedNonceAccount(ctx context.Context, address string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE nonce_accounts SET mix=?, call_id=?, updated_at=? WHERE address=? AND (mix IS NOT NULL OR call_id IS NOT NULL)", + nil, nil, time.Now().UTC(), address) + if err != nil { + return fmt.Errorf("UPDATE nonce_accounts %s %v", address, err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) ListLockedNonceAccounts(ctx context.Context) ([]*NonceAccount, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + sql := fmt.Sprintf("SELECT %s FROM nonce_accounts WHERE mix IS NOT NULL OR call_id IS NOT NULL ORDER BY updated_at ASC LIMIT 100", strings.Join(nonceAccountCols, ",")) + rows, err := s.db.QueryContext(ctx, sql) + if err != nil { + return nil, err + } + defer rows.Close() + + var as []*NonceAccount + for rows.Next() { + nonce, err := nonceAccountFromRow(rows) + if err != nil { + return nil, err + } + as = append(as, nonce) + } + return as, nil +} + +func (s *SQLite3Store) ListNonceAccounts(ctx context.Context) ([]*NonceAccount, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + sql := fmt.Sprintf("SELECT %s FROM nonce_accounts LIMIT 500", strings.Join(nonceAccountCols, ",")) + rows, err := s.db.QueryContext(ctx, sql) + if err != nil { + return nil, err + } + defer rows.Close() + + var as []*NonceAccount + for rows.Next() { + nonce, err := nonceAccountFromRow(rows) + if err != nil { + return nil, err + } + as = append(as, nonce) + } + return as, nil +} + +func (s *SQLite3Store) ReadNonceAccount(ctx context.Context, address string) (*NonceAccount, error) { + query := fmt.Sprintf("SELECT %s FROM nonce_accounts WHERE address=?", strings.Join(nonceAccountCols, ",")) + row := s.db.QueryRowContext(ctx, query, address) + + return nonceAccountFromRow(row) +} + +func (s *SQLite3Store) ReadNonceAccountByCall(ctx context.Context, callId string) (*NonceAccount, error) { + query := fmt.Sprintf("SELECT %s FROM nonce_accounts WHERE call_id=?", strings.Join(nonceAccountCols, ",")) + row := s.db.QueryRowContext(ctx, query, callId) + + return nonceAccountFromRow(row) +} + +func (s *SQLite3Store) ReadSpareNonceAccount(ctx context.Context) (*NonceAccount, error) { + query := fmt.Sprintf("SELECT %s FROM nonce_accounts WHERE mix IS NULL AND call_id IS NULL ORDER BY updated_at ASC LIMIT 1", strings.Join(nonceAccountCols, ",")) + row := s.db.QueryRowContext(ctx, query) + + return nonceAccountFromRow(row) +} + +func (s *SQLite3Store) CountNonceAccounts(ctx context.Context) (int, error) { + query := "SELECT COUNT(*) FROM nonce_accounts" + row := s.db.QueryRowContext(ctx, query) + + var count int + err := row.Scan(&count) + if err == sql.ErrNoRows { + return 0, nil + } + return count, err +} diff --git a/computer/store/output.go b/computer/store/output.go new file mode 100644 index 00000000..3b7f7682 --- /dev/null +++ b/computer/store/output.go @@ -0,0 +1,95 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/bot-api-go-client/v3" + "github.com/MixinNetwork/safe/common" +) + +type UserOutput struct { + OutputId string + UserId string + TransactionHash string + OutputIndex int + AssetId string + ChainId string + Amount string + State byte + SignedBy sql.NullString + CreatedAt time.Time + UpdatedAt time.Time + + Asset bot.AssetNetwork +} + +var userOutputCols = []string{"output_id", "user_id", "transaction_hash", "output_index", "asset_id", "chain_id", "amount", "state", "signed_by", "created_at", "updated_at"} + +func userOutputFromRow(row Row) (*UserOutput, error) { + var output UserOutput + err := row.Scan(&output.OutputId, &output.UserId, &output.TransactionHash, &output.OutputIndex, &output.AssetId, &output.ChainId, &output.Amount, &output.State, &output.SignedBy, &output.CreatedAt, &output.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &output, err +} + +func (s *SQLite3Store) WriteUserDepositWithRequest(ctx context.Context, req *Request, output *UserOutput) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + vals := []any{output.OutputId, output.UserId, output.TransactionHash, output.OutputIndex, output.AssetId, output.ChainId, output.Amount, output.State, output.SignedBy, output.CreatedAt, output.UpdatedAt} + err = s.execOne(ctx, tx, buildInsertionSQL("user_outputs", userOutputCols), vals...) + if err != nil { + return fmt.Errorf("INSERT user_outputs %v", err) + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) ListUserOutputsByHashAndState(ctx context.Context, user_id, hash string, state byte) ([]*UserOutput, error) { + vals := []any{user_id, hash} + query := fmt.Sprintf("SELECT %s FROM user_outputs WHERE user_id=? AND transaction_hash=?", strings.Join(userOutputCols, ",")) + if state > 0 { + query += " AND state=?" + vals = append(vals, state) + } + query += " ORDER BY created_at ASC LIMIT 100" + return s.listUserOutputsByQuery(ctx, query, vals...) +} + +func (s *SQLite3Store) listUserOutputsByQuery(ctx context.Context, query string, params ...any) ([]*UserOutput, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + rows, err := s.db.QueryContext(ctx, query, params...) + if err != nil { + return nil, err + } + defer rows.Close() + + var outputs []*UserOutput + for rows.Next() { + output, err := userOutputFromRow(rows) + if err != nil { + return nil, err + } + outputs = append(outputs, output) + } + return outputs, nil +} diff --git a/computer/store/request.go b/computer/store/request.go new file mode 100644 index 00000000..f9ef7d2e --- /dev/null +++ b/computer/store/request.go @@ -0,0 +1,179 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/mtg" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" +) + +type Request struct { + Id string + MixinHash crypto.Hash + MixinIndex int + AssetId string + Amount decimal.Decimal + Role uint8 + Action uint8 + ExtraHEX string + State uint8 + CreatedAt time.Time + Sequence uint64 + + Output *mtg.Action +} + +func (req *Request) ExtraBytes() []byte { + return common.DecodeHexOrPanic(req.ExtraHEX) +} + +func (r *Request) VerifyFormat() error { + if r.CreatedAt.IsZero() { + panic(r.Output.OutputId) + } + if r.Action == 0 || r.Role == 0 || r.State == 0 { + return fmt.Errorf("invalid request action %v", r) + } + id, err := uuid.FromString(r.AssetId) + if err != nil || id.IsNil() || id.String() != r.AssetId { + return fmt.Errorf("invalid request asset %v", r) + } + if r.Amount.Cmp(decimal.New(1, -8)) < 0 { + return fmt.Errorf("invalid request amount %v", r) + } + if !r.MixinHash.HasValue() { + return fmt.Errorf("invalid request mixin %v", r) + } + return nil +} + +var requestCols = []string{"request_id", "mixin_hash", "mixin_index", "asset_id", "amount", "role", "action", "extra", "state", "created_at", "updated_at", "sequence"} + +func requestFromRow(row *sql.Row) (*Request, error) { + var mh string + var r Request + err := row.Scan(&r.Id, &mh, &r.MixinIndex, &r.AssetId, &r.Amount, &r.Role, &r.Action, &r.ExtraHEX, &r.State, &r.CreatedAt, &time.Time{}, &r.Sequence) + if err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, err + } + r.MixinHash, err = crypto.HashFromString(mh) + return &r, err +} + +func (s *SQLite3Store) WriteRequestIfNotExist(ctx context.Context, req *Request) error { + if req.State == 0 || req.Role == 0 { + panic(req) + } + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT request_id FROM requests WHERE request_id=?", req.Id) + if err != nil || existed { + return err + } + + vals := []any{req.Id, req.MixinHash.String(), req.MixinIndex, req.AssetId, req.Amount, req.Role, req.Action, req.ExtraHEX, req.State, req.CreatedAt, req.CreatedAt, req.Sequence} + err = s.execOne(ctx, tx, buildInsertionSQL("requests", requestCols), vals...) + if err != nil { + return fmt.Errorf("INSERT requests %v", err) + } + return tx.Commit() +} + +func (s *SQLite3Store) WriteDepositRequestIfNotExist(ctx context.Context, out *mtg.Action, state int, txs []*mtg.Transaction, compaction string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT request_id FROM requests WHERE request_id=?", out.OutputId) + if err != nil || existed { + return err + } + + vals := []any{out.OutputId, out.TransactionHash, out.OutputIndex, out.AssetId, out.Amount, 0, 0, "", state, out.SequencerCreatedAt, out.SequencerCreatedAt, out.Sequence} + err = s.execOne(ctx, tx, buildInsertionSQL("requests", requestCols), vals...) + if err != nil { + return fmt.Errorf("INSERT requests %v", err) + } + + err = s.writeActionResult(ctx, tx, out.OutputId, compaction, txs, out.OutputId) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadRequest(ctx context.Context, id string) (*Request, error) { + query := fmt.Sprintf("SELECT %s FROM requests WHERE request_id=?", strings.Join(requestCols, ",")) + row := s.db.QueryRowContext(ctx, query, id) + + return requestFromRow(row) +} + +func (s *SQLite3Store) ReadRequestByHash(ctx context.Context, hash string) (*Request, error) { + query := fmt.Sprintf("SELECT %s FROM requests WHERE mixin_hash=?", strings.Join(requestCols, ",")) + row := s.db.QueryRowContext(ctx, query, hash) + + return requestFromRow(row) +} + +func (s *SQLite3Store) FailRequest(ctx context.Context, req *Request, compaction string, txs []*mtg.Transaction) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE requests SET state=?, updated_at=? WHERE request_id=? AND state=?", + common.RequestStateFailed, time.Now().UTC(), req.Id, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("UPDATE requests %v", err) + } + + err = s.writeActionResult(ctx, tx, req.Output.OutputId, compaction, txs, req.Id) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) ReadLatestRequest(ctx context.Context) (*Request, error) { + query := fmt.Sprintf("SELECT %s FROM requests ORDER BY created_at DESC, request_id DESC LIMIT 1", strings.Join(requestCols, ",")) + row := s.db.QueryRowContext(ctx, query) + + return requestFromRow(row) +} + +func (s *SQLite3Store) finishRequest(ctx context.Context, tx *sql.Tx, req *Request, txs []*mtg.Transaction, compaction string) error { + err := s.execOne(ctx, tx, "UPDATE requests SET state=?, updated_at=? WHERE request_id=? AND state=?", + common.RequestStateDone, time.Now().UTC(), req.Id, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("UPDATE requests %v", err) + } + return s.writeActionResult(ctx, tx, req.Output.OutputId, compaction, txs, req.Id) +} diff --git a/computer/store/schema.sql b/computer/store/schema.sql new file mode 100644 index 00000000..8b134d2d --- /dev/null +++ b/computer/store/schema.sql @@ -0,0 +1,246 @@ +CREATE TABLE IF NOT EXISTS properties ( + key VARCHAR NOT NULL, + value VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY ('key') +); + + +CREATE TABLE IF NOT EXISTS keys ( + public VARCHAR NOT NULL, + fingerprint VARCHAR NOT NULL, + share VARCHAR NOT NULL, + session_id VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + confirmed_at TIMESTAMP, + backed_up_at TIMESTAMP, + PRIMARY KEY ('public') +); + +CREATE UNIQUE INDEX IF NOT EXISTS keys_by_session_id ON keys(session_id); +CREATE UNIQUE INDEX IF NOT EXISTS keys_by_fingerprint ON keys(fingerprint); +CREATE INDEX IF NOT EXISTS keys_by_confirmed ON keys(confirmed_at); + + +CREATE TABLE IF NOT EXISTS sessions ( + session_id VARCHAR NOT NULL, + request_id VARCHAR NOT NULL, + mixin_hash VARCHAR NOT NULL, + mixin_index INTEGER NOT NULL, + sub_index INTEGER NOT NULL, + operation INTEGER NOT NULL, + public VARCHAR NOT NULL, + extra VARCHAR NOT NULL, + state INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + committed_at TIMESTAMP, + prepared_at TIMESTAMP, + PRIMARY KEY ('session_id') +); + +CREATE UNIQUE INDEX IF NOT EXISTS sessions_by_mixin_hash_index ON sessions(mixin_hash, mixin_index, sub_index); +CREATE INDEX IF NOT EXISTS sessions_by_state_created ON sessions(state, created_at); +CREATE INDEX IF NOT EXISTS sessions_by_state_operation_created_index ON sessions(state, operation, created_at, sub_index); + + +CREATE TABLE IF NOT EXISTS session_signers ( + session_id VARCHAR NOT NULL, + signer_id VARCHAR NOT NULL, + extra VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY ('session_id', 'signer_id') +); + + +CREATE TABLE IF NOT EXISTS session_works ( + session_id VARCHAR NOT NULL, + signer_id VARCHAR NOT NULL, + round INTEGER NOT NULL, + extra VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('session_id', 'signer_id', 'round') +); + + +CREATE TABLE IF NOT EXISTS requests ( + request_id VARCHAR NOT NULL, + mixin_hash VARCHAR NOT NULL, + mixin_index INTEGER NOT NULL, + asset_id VARCHAR NOT NULL, + amount VARCHAR NOT NULL, + role INTEGER NOT NULL, + action INTEGER NOT NULL, + extra VARCHAR NOT NULL, + state INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + sequence INTEGER NOT NULL, + PRIMARY KEY ('request_id') +); + +CREATE UNIQUE INDEX IF NOT EXISTS requests_by_mixin_hash_index ON requests(mixin_hash, mixin_index); +CREATE INDEX IF NOT EXISTS requests_by_hash ON requests(mixin_hash); +CREATE INDEX IF NOT EXISTS requests_by_state_created ON requests(state, created_at); + + + +CREATE TABLE IF NOT EXISTS operation_params ( + request_id VARCHAR NOT NULL, + price_asset VARCHAR NOT NULL, + price_amount VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('request_id') +); + +CREATE INDEX IF NOT EXISTS operation_params_by_created ON operation_params(created_at); + + +CREATE TABLE IF NOT EXISTS users ( + user_id VARCHAR NOT NULL, + request_id VARCHAR NOT NULL, + mix_address VARCHAR NOT NULL, + chain_address VARCHAR NOT NULL, + public VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('user_id') +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_by_mix_address ON users(mix_address); +CREATE UNIQUE INDEX IF NOT EXISTS users_by_chain_address ON users(chain_address); +CREATE INDEX IF NOT EXISTS users_by_created ON users(created_at); + + + +CREATE TABLE IF NOT EXISTS external_assets ( + asset_id VARCHAR NOT NULL, + uri TEXT, + icon_url TEXT, + deployed_hash VARCHAR, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('asset_id') +); + +CREATE INDEX IF NOT EXISTS assets_by_deployed ON external_assets(icon_url, deployed_hash); + + +CREATE TABLE IF NOT EXISTS deployed_assets ( + asset_id VARCHAR NOT NULL, + chain_id VARCHAR NOT NULL, + address VARCHAR NOT NULL, + decimals INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('asset_id') +); + + +CREATE TABLE IF NOT EXISTS system_calls ( + id VARCHAR NOT NULL, + superior_id VARCHAR NOT NULL, + request_hash VARCHAR NOT NULL, + call_type VARCHAR NOT NULL, + nonce_account VARCHAR NOT NULL, + public VARCHAR NOT NULL, + skip_postprocess BOOLEAN NOT NULL, + message_hash VARCHAR NOT NULL, + raw TEXT NOT NULL, + state INTEGER NOT NULL, + withdrawal_traces VARCHAR, + signature VARCHAR, + request_signer_at TIMESTAMP, + hash VARCHAR, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY ('id') +); + +CREATE UNIQUE INDEX IF NOT EXISTS calls_by_message ON system_calls(message_hash); +CREATE INDEX IF NOT EXISTS calls_by_hash ON system_calls(hash); +CREATE INDEX IF NOT EXISTS calls_by_state_withdrawal_created ON system_calls(state, withdrawal_traces, created_at); +CREATE INDEX IF NOT EXISTS calls_by_state_signature_created ON system_calls(state, withdrawal_traces, signature, created_at); +CREATE INDEX IF NOT EXISTS calls_by_superior_state_created ON system_calls(superior_id, state, created_at); + + +CREATE TABLE IF NOT EXISTS user_outputs ( + output_id VARCHAR NOT NULL, + user_id VARCHAR NOT NULL, + transaction_hash VARCHAR NOT NULL, + output_index INTEGER NOT NULL, + asset_id VARCHAR NOT NULL, + chain_id VARCHAR NOT NULL, + amount VARCHAR NOT NULL, + state INTEGER NOT NULL, + signed_by VARCHAR, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY ('output_id') +); + +CREATE INDEX IF NOT EXISTS outputs_by_user_hash_state_created ON user_outputs(user_id, transaction_hash, state, created_at); + + +CREATE TABLE IF NOT EXISTS nonce_accounts ( + address VARCHAR NOT NULL, + hash VARCHAR NOT NULL, + mix VARCHAR, + call_id VARCHAR, + updated_by VARCHAR, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY ('address') +); + +CREATE INDEX IF NOT EXISTS nonces_by_mix_call_updated ON nonce_accounts(mix, call_id, updated_at); + + +CREATE TABLE IF NOT EXISTS confirmed_withdrawals ( + hash VARCHAR NOT NULL, + trace_id VARCHAR NOT NULL, + call_id VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('hash') +); + + +CREATE TABLE IF NOT EXISTS fees ( + fee_id VARCHAR NOT NULL, + ratio VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('fee_id') +); + +CREATE INDEX IF NOT EXISTS fees_by_created ON fees(created_at); + + +-- TODO use a separate sqlite3 for caches +CREATE TABLE IF NOT EXISTS caches ( + key VARCHAR NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('key') +); + +CREATE INDEX IF NOT EXISTS caches_by_created ON caches(created_at); + + +CREATE TABLE IF NOT EXISTS failed_calls ( + call_id VARCHAR NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('call_id') +); + + +CREATE TABLE IF NOT EXISTS action_results ( + output_id VARCHAR NOT NULL, + compaction VARCHAR NOT NULL, + transactions TEXT NOT NULL, + request_id VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY ('output_id') +); + +CREATE INDEX IF NOT EXISTS action_results_by_request ON action_results(request_id); diff --git a/computer/store/session.go b/computer/store/session.go new file mode 100644 index 00000000..9b66d2ff --- /dev/null +++ b/computer/store/session.go @@ -0,0 +1,303 @@ +package store + +import ( + "context" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/MixinNetwork/safe/common" +) + +type Session struct { + Id string + RequestId string + MixinHash string + MixinIndex int + Index int + Operation byte + Public string + Extra string + State byte + CreatedAt time.Time + PreparedAt sql.NullTime +} + +func (r *Session) AsOperation() *common.Operation { + extra, err := hex.DecodeString(r.Extra) + if err != nil { + panic(err) + } + + return &common.Operation{ + Id: r.Id, + Type: r.Operation, + Public: r.Public, + Extra: extra, + } +} + +func (s *SQLite3Store) ReadSession(ctx context.Context, sessionId string) (*Session, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + var r Session + query := "SELECT session_id, request_id, mixin_hash, mixin_index, operation, public, extra, state, created_at, prepared_at FROM sessions WHERE session_id=?" + row := s.db.QueryRowContext(ctx, query, sessionId) + err := row.Scan(&r.Id, &r.RequestId, &r.MixinHash, &r.MixinIndex, &r.Operation, &r.Public, &r.Extra, &r.State, &r.CreatedAt, &r.PreparedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &r, err +} + +func (s *SQLite3Store) WriteSessionsWithRequest(ctx context.Context, req *Request, sessions []*Session, needsCommittment bool) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + for _, session := range sessions { + existed, err := s.checkExistence(ctx, tx, "SELECT session_id FROM sessions WHERE session_id=?", session.Id) + if err != nil || existed { + return err + } + + cols := []string{"session_id", "request_id", "mixin_hash", "mixin_index", "sub_index", "operation", "public", + "extra", "state", "created_at", "updated_at"} + vals := []any{session.Id, session.RequestId, session.MixinHash, session.MixinIndex, session.Index, session.Operation, session.Public, + session.Extra, common.RequestStateInitial, session.CreatedAt, session.CreatedAt} + if !needsCommittment { + cols = append(cols, "committed_at", "prepared_at") + vals = append(vals, session.CreatedAt, session.CreatedAt) + } + err = s.execOne(ctx, tx, buildInsertionSQL("sessions", cols), vals...) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT sessions %v", err) + } + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) writeSession(ctx context.Context, tx *sql.Tx, session *Session) error { + cols := []string{"session_id", "request_id", "mixin_hash", "mixin_index", "sub_index", "operation", "public", + "extra", "state", "created_at", "updated_at"} + vals := []any{session.Id, session.RequestId, session.MixinHash, session.MixinIndex, session.Index, session.Operation, session.Public, + session.Extra, common.RequestStateInitial, session.CreatedAt, session.CreatedAt} + err := s.execOne(ctx, tx, buildInsertionSQL("sessions", cols), vals...) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT sessions %v", err) + } + return nil +} + +func (s *SQLite3Store) FailSession(ctx context.Context, sessionId string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + // the pending state is important, because we needs to let the other nodes know our failed + // result, and the pending state allows the node to process this session accordingly + err = s.execOne(ctx, tx, "UPDATE sessions SET state=?, updated_at=? WHERE session_id=? AND state=?", + common.RequestStatePending, time.Now().UTC(), sessionId, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) MarkSessionPending(ctx context.Context, sessionId string, fingerprint string, extra []byte) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE sessions SET extra=?, state=?, updated_at=? WHERE session_id=? AND public=? AND state=? AND prepared_at IS NOT NULL", + hex.EncodeToString(extra), common.RequestStatePending, time.Now().UTC(), sessionId, fingerprint, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) MarkSessionCommitted(ctx context.Context, sessionId string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + committedAt := time.Now().UTC() + query := "UPDATE sessions SET committed_at=?, updated_at=? WHERE session_id=? AND state=? AND committed_at IS NULL" + err = s.execOne(ctx, tx, query, committedAt, committedAt, sessionId, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) MarkSessionPreparedWithRequest(ctx context.Context, req *Request, sessionId string, preparedAt time.Time) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "SELECT prepared_at FROM sessions WHERE session_id=? AND prepared_at IS NOT NULL" + existed, err := s.checkExistence(ctx, tx, query, sessionId) + if err != nil || existed { + return err + } + + query = "UPDATE sessions SET prepared_at=?, updated_at=? WHERE session_id=? AND state=? AND prepared_at IS NULL" + err = s.execOne(ctx, tx, query, preparedAt, preparedAt, sessionId, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) MarkSessionDone(ctx context.Context, sessionId string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE sessions SET state=?, updated_at=? WHERE session_id=? AND state=?", + common.RequestStateDone, time.Now().UTC(), sessionId, common.RequestStatePending) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) ListInitialSessions(ctx context.Context, limit int) ([]*Session, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + cols := "session_id, request_id, mixin_hash, mixin_index, operation, public, extra, state, created_at" + sql := fmt.Sprintf("SELECT %s FROM sessions WHERE state=? AND committed_at IS NULL AND prepared_at IS NULL ORDER BY operation DESC, created_at ASC, sub_index ASC, session_id ASC LIMIT %d", cols, limit) + return s.listSessionsByQuery(ctx, sql, common.RequestStateInitial) +} + +func (s *SQLite3Store) ListPreparedSessions(ctx context.Context, limit int) ([]*Session, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + cols := "session_id, request_id, mixin_hash, mixin_index, operation, public, extra, state, created_at" + sql := fmt.Sprintf("SELECT %s FROM sessions WHERE state=? AND committed_at IS NOT NULL AND prepared_at IS NOT NULL ORDER BY operation DESC, created_at ASC, session_id ASC LIMIT %d", cols, limit) + return s.listSessionsByQuery(ctx, sql, common.RequestStateInitial) +} + +func (s *SQLite3Store) ListPendingSessions(ctx context.Context, limit int) ([]*Session, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + cols := "session_id, request_id, mixin_hash, mixin_index, operation, public, extra, state, created_at" + sql := fmt.Sprintf("SELECT %s FROM sessions WHERE state=? ORDER BY created_at ASC, session_id ASC LIMIT %d", cols, limit) + return s.listSessionsByQuery(ctx, sql, common.RequestStatePending) +} + +func (s *SQLite3Store) listSessionsByQuery(ctx context.Context, sql string, state int) ([]*Session, error) { + rows, err := s.db.QueryContext(ctx, sql, state) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []*Session + for rows.Next() { + var r Session + err := rows.Scan(&r.Id, &r.RequestId, &r.MixinHash, &r.MixinIndex, &r.Operation, &r.Public, &r.Extra, &r.State, &r.CreatedAt) + if err != nil { + return nil, err + } + sessions = append(sessions, &r) + } + return sessions, nil +} + +type State struct { + Initial int + Pending int + Done int + Keys int +} + +func (s *SQLite3Store) SessionsState(ctx context.Context) (*State, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer common.Rollback(tx) + + var state State + row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM sessions WHERE state=?", common.RequestStateInitial) + err = row.Scan(&state.Initial) + if err != nil { + return nil, err + } + + row = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM sessions WHERE state=?", common.RequestStatePending) + err = row.Scan(&state.Pending) + if err != nil { + return nil, err + } + + row = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM sessions WHERE state=?", common.RequestStateDone) + err = row.Scan(&state.Done) + if err != nil { + return nil, err + } + + row = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM keys") + err = row.Scan(&state.Keys) + if err != nil { + return nil, err + } + + return &state, nil +} diff --git a/computer/store/signer.go b/computer/store/signer.go new file mode 100644 index 00000000..96decfb5 --- /dev/null +++ b/computer/store/signer.go @@ -0,0 +1,157 @@ +package store + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + "github.com/MixinNetwork/multi-party-sig/pkg/party" + "github.com/MixinNetwork/safe/common" +) + +func (s *SQLite3Store) PrepareSessionSignerIfNotExist(ctx context.Context, sessionId, signerId string, createdAt time.Time) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "SELECT extra FROM session_signers WHERE session_id=? AND signer_id=?" + existed, err := s.checkExistence(ctx, tx, query, sessionId, signerId) + if err != nil || existed { + return err + } + + cols := []string{"session_id", "signer_id", "extra", "created_at", "updated_at"} + err = s.execOne(ctx, tx, buildInsertionSQL("session_signers", cols), + sessionId, signerId, "", createdAt, createdAt) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT session_signers %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) WriteSessionSignerIfNotExist(ctx context.Context, sessionId, signerId string, extra []byte, createdAt time.Time, self bool) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT extra FROM session_signers WHERE session_id=? AND signer_id=?", sessionId, signerId) + if err != nil || existed { + return err + } + + cols := []string{"session_id", "signer_id", "extra", "created_at", "updated_at"} + err = s.execOne(ctx, tx, buildInsertionSQL("session_signers", cols), + sessionId, signerId, hex.EncodeToString(extra), createdAt, createdAt) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT session_signers %v", err) + } + + existed, err = s.checkExistence(ctx, tx, "SELECT session_id FROM sessions WHERE session_id=? AND state=?", sessionId, common.RequestStateInitial) + if err != nil { + return err + } + if self && existed { + err = s.execOne(ctx, tx, "UPDATE sessions SET state=?, updated_at=? WHERE session_id=? AND state=?", + common.RequestStatePending, createdAt, sessionId, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + } + + return tx.Commit() +} + +func (s *SQLite3Store) UpdateSessionSigner(ctx context.Context, sessionId, signerId string, extra []byte, updatedAt time.Time, self bool) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "SELECT extra FROM session_signers WHERE session_id=? AND signer_id=?" + existed, err := s.checkExistence(ctx, tx, query, sessionId, signerId) + if err != nil || !existed { + return err + } + + query = "UPDATE session_signers SET extra=?, updated_at=? WHERE session_id=? AND signer_id=?" + err = s.execOne(ctx, tx, query, hex.EncodeToString(extra), updatedAt, sessionId, signerId) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE session_signers %v", err) + } + + existed, err = s.checkExistence(ctx, tx, "SELECT session_id FROM sessions WHERE session_id=? AND state=?", sessionId, common.RequestStateInitial) + if err != nil { + return err + } + if self && existed { + err = s.execOne(ctx, tx, "UPDATE sessions SET state=?, updated_at=? WHERE session_id=? AND state=?", + common.RequestStatePending, updatedAt, sessionId, common.RequestStateInitial) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE sessions %v", err) + } + } + + return tx.Commit() +} + +func (s *SQLite3Store) ListSessionPreparedMembers(ctx context.Context, sessionId string, threshold int) ([]party.ID, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + query := fmt.Sprintf("SELECT signer_id FROM session_signers WHERE session_id=? ORDER BY created_at ASC LIMIT %d", threshold) + rows, err := s.db.QueryContext(ctx, query, sessionId) + if err != nil { + return nil, err + } + defer rows.Close() + + var signers []party.ID + for rows.Next() { + var signer string + err := rows.Scan(&signer) + if err != nil { + return nil, err + } + signers = append(signers, party.ID(signer)) + } + return signers, nil +} + +func (s *SQLite3Store) ListSessionSignerResults(ctx context.Context, sessionId string) (map[string]string, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + query := "SELECT signer_id, extra FROM session_signers WHERE session_id=?" + rows, err := s.db.QueryContext(ctx, query, sessionId) + if err != nil { + return nil, err + } + defer rows.Close() + + var signer, extra string + signers := make(map[string]string) + for rows.Next() { + err := rows.Scan(&signer, &extra) + if err != nil { + return nil, err + } + signers[signer] = extra + } + return signers, nil +} diff --git a/computer/store/store.go b/computer/store/store.go new file mode 100644 index 00000000..a80e02fe --- /dev/null +++ b/computer/store/store.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + "strings" + "sync" + "time" + + "github.com/MixinNetwork/safe/common" +) + +//go:embed schema.sql +var SCHEMA string + +type SQLite3Store struct { + db *sql.DB + mutex *sync.RWMutex +} + +type SignResult struct { + Signature []byte + SSID []byte +} + +func OpenSQLite3Store(path string) (*SQLite3Store, error) { + db, err := common.OpenSQLite3Store(path, SCHEMA) + if err != nil { + return nil, err + } + return &SQLite3Store{ + db: db, + mutex: new(sync.RWMutex), + }, nil +} + +func (s *SQLite3Store) Close() error { + return s.db.Close() +} + +func buildInsertionSQL(table string, cols []string) string { + vals := strings.Repeat("?, ", len(cols)) + return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(cols, ","), vals[:len(vals)-2]) +} + +func (s *SQLite3Store) execOne(ctx context.Context, tx *sql.Tx, sql string, params ...any) error { + res, err := tx.ExecContext(ctx, sql, params...) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil || rows != 1 { + return fmt.Errorf("SQLite3Store.execOne(%s) => %d %v", sql, rows, err) + } + return nil +} + +func (s *SQLite3Store) checkExistence(ctx context.Context, tx *sql.Tx, sql string, params ...any) (bool, error) { + rows, err := tx.QueryContext(ctx, sql, params...) + if err != nil { + return false, err + } + defer rows.Close() + + return rows.Next(), nil +} + +func (s *SQLite3Store) ReadProperty(ctx context.Context, k string) (string, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + row := s.db.QueryRowContext(ctx, "SELECT value FROM properties WHERE key=?", k) + err := row.Scan(&k) + if err == sql.ErrNoRows { + return "", nil + } + return k, err +} + +func (s *SQLite3Store) WriteProperty(ctx context.Context, k, v string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.writeProperty(ctx, tx, k, v) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) writeProperty(ctx context.Context, tx *sql.Tx, k, v string) error { + existed, err := s.checkExistence(ctx, tx, "SELECT value FROM properties WHERE key=?", k) + if err != nil { + return err + } + + createdAt := time.Now().UTC() + if existed { + err = s.execOne(ctx, tx, "UPDATE properties SET value=?, updated_at=? WHERE key=?", v, createdAt, k) + if err != nil { + return fmt.Errorf("UPDATE properties %v", err) + } + } else { + cols := []string{"key", "value", "created_at", "updated_at"} + err = s.execOne(ctx, tx, buildInsertionSQL("properties", cols), k, v, createdAt, createdAt) + if err != nil { + return fmt.Errorf("INSERT properties %v", err) + } + } + + return nil +} + +type Row interface { + Scan(dest ...any) error +} diff --git a/computer/store/test.go b/computer/store/test.go new file mode 100644 index 00000000..3fcd9148 --- /dev/null +++ b/computer/store/test.go @@ -0,0 +1,107 @@ +package store + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/safe/common" +) + +func (s *SQLite3Store) TestWriteKey(ctx context.Context, id, public string, conf []byte, saved bool) error { + if !common.CheckTestEnvironment(ctx) { + return fmt.Errorf("invalid env") + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + existed, err := s.checkExistence(ctx, tx, "SELECT public FROM keys WHERE public=?", public) + if err != nil || existed { + return err + } + + timestamp := time.Now().UTC() + share := common.Base91Encode(conf) + fingerprint := hex.EncodeToString(common.Fingerprint(public)) + cols := []string{"public", "fingerprint", "share", "session_id", "created_at", "updated_at", "confirmed_at"} + values := []any{public, fingerprint, share, id, timestamp, timestamp, timestamp} + if saved { + cols = append(cols, "backed_up_at") + values = append(values, timestamp) + } + + err = s.execOne(ctx, tx, buildInsertionSQL("keys", cols), values...) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT keys %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) TestWriteCall(ctx context.Context, call *SystemCall) error { + if !common.CheckTestEnvironment(ctx) { + panic(ctx) + } + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + vals := []any{call.RequestId, call.Superior, call.RequestHash, call.Type, call.NonceAccount, call.Public, call.SkipPostProcess, call.MessageHash, call.Raw, call.State, call.WithdrawalTraces, call.Signature, call.RequestSignerAt, call.Hash, call.CreatedAt, call.UpdatedAt} + err = s.execOne(ctx, tx, buildInsertionSQL("system_calls", systemCallCols), vals...) + if err != nil { + return fmt.Errorf("INSERT system_calls %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) TestWriteSignSession(ctx context.Context, call *SystemCall, sessions []*Session) error { + if !common.CheckTestEnvironment(ctx) { + panic(ctx) + } + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + now := time.Now().UTC() + query := "UPDATE system_calls SET request_signer_at=?, updated_at=? WHERE id=? AND state=? AND signature IS NULL" + err = s.execOne(ctx, tx, query, now, now, call.RequestId, common.RequestStatePending) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE keys %v", err) + } + + for _, session := range sessions { + err = s.writeSession(ctx, tx, session) + if err != nil { + return err + } + } + + return tx.Commit() +} + +func (s *SQLite3Store) TestReadPendingRequest(ctx context.Context) (*Request, error) { + query := fmt.Sprintf("SELECT %s FROM requests WHERE state=? ORDER BY created_at ASC, request_id ASC LIMIT 1", strings.Join(requestCols, ",")) + row := s.db.QueryRowContext(ctx, query, common.RequestStateInitial) + + return requestFromRow(row) +} diff --git a/computer/store/user.go b/computer/store/user.go new file mode 100644 index 00000000..2dfde358 --- /dev/null +++ b/computer/store/user.go @@ -0,0 +1,193 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "math/big" + "strings" + "time" + + "github.com/MixinNetwork/safe/common" +) + +var ( + StartUserId = big.NewInt(0).Exp(big.NewInt(2), big.NewInt(48), nil) + DefaultPath = []byte{0, 0, 0, 0, 0, 0, 0, 0} +) + +type User struct { + UserId string + RequestId string + MixAddress string + ChainAddress string + Public string // public is the master with defaultPath controlled by mpc + CreatedAt time.Time +} + +var userCols = []string{"user_id", "request_id", "mix_address", "chain_address", "public", "created_at"} + +func userFromRow(row Row) (*User, error) { + var u User + err := row.Scan(&u.UserId, &u.RequestId, &u.MixAddress, &u.ChainAddress, &u.Public, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &u, err +} + +func (u *User) Id() *big.Int { + b, ok := new(big.Int).SetString(u.UserId, 10) + if !ok || b.Sign() <= 0 { + panic(u.UserId) + } + if b.Cmp(StartUserId) < 0 { + panic(u.UserId) + } + return b +} + +func (u *User) IdBytes() []byte { + bid := u.Id() + data := make([]byte, 8) + data = bid.FillBytes(data) + return data +} + +func (u *User) FingerprintWithEmptyPath() []byte { + fp := common.Fingerprint(u.Public) + fp = append(fp, DefaultPath...) + return fp +} + +func (u *User) FingerprintWithPath() []byte { + fp := common.Fingerprint(u.Public) + fp = append(fp, u.IdBytes()...) + return fp +} + +func (s *SQLite3Store) GetNextUserId(ctx context.Context) (*big.Int, error) { + u, err := s.ReadLatestUser(ctx) + if err != nil { + return nil, err + } + id := StartUserId + if u != nil { + id = u.Id() + } + id = big.NewInt(0).Add(id, big.NewInt(1)) + return id, nil +} + +func (s *SQLite3Store) ReadLatestUser(ctx context.Context) (*User, error) { + query := fmt.Sprintf("SELECT %s FROM users ORDER BY created_at DESC LIMIT 1", strings.Join(userCols, ",")) + row := s.db.QueryRowContext(ctx, query) + + return userFromRow(row) +} + +func (s *SQLite3Store) ReadUser(ctx context.Context, id string) (*User, error) { + query := fmt.Sprintf("SELECT %s FROM users WHERE user_id=?", strings.Join(userCols, ",")) + row := s.db.QueryRowContext(ctx, query, id) + + return userFromRow(row) +} + +func (s *SQLite3Store) ReadUserByMixAddress(ctx context.Context, address string) (*User, error) { + query := fmt.Sprintf("SELECT %s FROM users WHERE mix_address=?", strings.Join(userCols, ",")) + row := s.db.QueryRowContext(ctx, query, address) + + return userFromRow(row) +} + +func (s *SQLite3Store) ReadUserByChainAddress(ctx context.Context, address string) (*User, error) { + query := fmt.Sprintf("SELECT %s FROM users WHERE chain_address=?", strings.Join(userCols, ",")) + row := s.db.QueryRowContext(ctx, query, address) + + return userFromRow(row) +} + +func (s *SQLite3Store) WriteUserWithRequest(ctx context.Context, req *Request, id, mixAddress, chainAddress, master string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + vals := []any{id, req.Id, mixAddress, chainAddress, master, time.Now().UTC()} + err = s.execOne(ctx, tx, buildInsertionSQL("users", userCols), vals...) + if err != nil { + return fmt.Errorf("INSERT users %v", err) + } + + err = s.finishRequest(ctx, tx, req, nil, "") + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) CountUsers(ctx context.Context) (int, error) { + query := "SELECT COUNT(*) FROM users" + row := s.db.QueryRowContext(ctx, query) + + var count int + err := row.Scan(&count) + if err == sql.ErrNoRows { + return 0, nil + } + return count, err +} + +func (s *SQLite3Store) CheckInternalAccounts(ctx context.Context, accounts []string) (int, error) { + if len(accounts) == 0 { + return 0, nil + } + + s.mutex.RLock() + defer s.mutex.RUnlock() + + placeholders := strings.Repeat("?, ", len(accounts)) + placeholders = strings.TrimSuffix(placeholders, ", ") + + args := make([]any, len(accounts)) + for i, addr := range accounts { + args[i] = addr + } + + query := fmt.Sprintf("SELECT COUNT(1) FROM users WHERE chain_address IN (%s)", placeholders) + row := s.db.QueryRowContext(ctx, query, args...) + + var count int + err := row.Scan(&count) + if err == sql.ErrNoRows { + return 0, nil + } + return count, err +} + +func (s *SQLite3Store) ListNewUsersAfter(ctx context.Context, offset time.Time) ([]*User, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + sql := fmt.Sprintf("SELECT %s FROM users WHERE created_at>? ORDER BY created_at ASC LIMIT 100", strings.Join(userCols, ",")) + rows, err := s.db.QueryContext(ctx, sql, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var us []*User + for rows.Next() { + call, err := userFromRow(rows) + if err != nil { + return nil, err + } + us = append(us, call) + } + return us, nil +} diff --git a/computer/store/withdrawal.go b/computer/store/withdrawal.go new file mode 100644 index 00000000..a0d7a29e --- /dev/null +++ b/computer/store/withdrawal.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/MixinNetwork/safe/common" +) + +type ConfirmedWithdrawal struct { + Hash string + TraceId string + CallId string + CreatedAt time.Time +} + +var confirmedWithdrawalCols = []string{"hash", "trace_id", "call_id", "created_at"} + +func (s *SQLite3Store) WriteConfirmedWithdrawal(ctx context.Context, w *ConfirmedWithdrawal) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + vals := []any{w.Hash, w.TraceId, w.CallId, w.CreatedAt} + err = s.execOne(ctx, tx, buildInsertionSQL("confirmed_withdrawals", confirmedWithdrawalCols), vals...) + if err != nil { + return fmt.Errorf("INSERT confirmed_withdrawals %v", err) + } + + err = s.writeProperty(ctx, tx, WithdrawalConfirmRequestTimeKey, w.CreatedAt.Format(time.RFC3339Nano)) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) CheckUnconfirmedWithdrawals(ctx context.Context, call *SystemCall) (bool, error) { + if !call.WithdrawalTraces.Valid { + panic(call.RequestId) + } + ids := call.GetWithdrawalIds() + if len(ids) == 0 { + return false, nil + } + + s.mutex.RLock() + defer s.mutex.RUnlock() + + placeholders := strings.Repeat("?, ", len(ids)) + placeholders = strings.TrimSuffix(placeholders, ", ") + + args := make([]any, len(ids)) + for i, addr := range ids { + args[i] = addr + } + + query := fmt.Sprintf("SELECT COUNT(1) FROM confirmed_withdrawals WHERE trace_id IN (%s)", placeholders) + row := s.db.QueryRowContext(ctx, query, args...) + + var count int + err := row.Scan(&count) + if err == sql.ErrNoRows { + return true, nil + } else if err != nil { + return true, err + } + fmt.Println("count", count) + return count < len(ids), err +} diff --git a/computer/store/work.go b/computer/store/work.go new file mode 100644 index 00000000..9eb08d8c --- /dev/null +++ b/computer/store/work.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/MixinNetwork/multi-party-sig/pkg/party" + "github.com/MixinNetwork/safe/common" +) + +func (s *SQLite3Store) WriteSessionWorkIfNotExist(ctx context.Context, sessionId, signerId string, round int, extra []byte) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + query := "SELECT created_at FROM session_works WHERE session_id=? AND signer_id=? AND round=?" + existed, err := s.checkExistence(ctx, tx, query, sessionId, signerId, round) + if err != nil || existed { + return err + } + + cols := []string{"session_id", "signer_id", "round", "extra", "created_at"} + err = s.execOne(ctx, tx, buildInsertionSQL("session_works", cols), + sessionId, signerId, round, common.Base91Encode(extra), time.Now().UTC()) + if err != nil { + return fmt.Errorf("SQLite3Store INSERT session_works %v", err) + } + + return tx.Commit() +} + +func (s *SQLite3Store) CountDailyWorks(ctx context.Context, members []party.ID, begin, end time.Time) ([]int, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer common.Rollback(tx) + + works := make([]int, len(members)) + for i, id := range members { + var work int + sql := "SELECT COUNT(*) FROM session_works WHERE signer_id=? AND created_at>? AND created_at %v %v", requestHash, req, err)) + } + ver, err := node.group.ReadKernelTransactionUntilSufficient(ctx, req.MixinHash.String()) + if err != nil || ver == nil { + panic(fmt.Errorf("group.ReadKernelTransactionUntilSufficient(%s) => %v %v", req.MixinHash.String(), ver, err)) + } + if common.CheckTestEnvironment(ctx) { + ver.References = readOutputReferences(req.Id) + } + + var storage *crypto.Hash + for _, ref := range ver.References { + os, hash, err := node.getSystemCallReferenceTx(ctx, uid, ref.String(), state) + if err != nil { + return nil, nil, err + } + if len(os) > 0 { + outputs = append(outputs, os...) + } + if hash == nil { + continue + } + if storage == nil { + storage = hash + } else if storage.String() != hash.String() { + panic(storage.String()) + } + } + return outputs, storage, nil +} + +func (node *Node) getSystemCallReferenceTx(ctx context.Context, uid, hash string, state byte) ([]*store.UserOutput, *crypto.Hash, error) { + ver, err := node.group.ReadKernelTransactionUntilSufficient(ctx, hash) + if err != nil || ver == nil { + panic(fmt.Errorf("group.ReadKernelTransactionUntilSufficient(%s) => %v %v", hash, ver, err)) + } + if common.CheckTestEnvironment(ctx) { + value, err := node.store.ReadProperty(ctx, hash) + if err != nil { + panic(err) + } + if len(value) > 0 { + switch hash { + case "a8eed784060b200ea7f417309b12a33ced8344c24f5cdbe0237b7fc06125f459", "01c43005fd06e0b8f06a0af04faf7530331603e352a11032afd0fd9dbd84e8ee": + raw := common.DecodeHexOrPanic(value) + ver, err = mc.UnmarshalVersionedTransaction(raw) + if err != nil { + panic(err) + } + default: + extra, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + panic(err) + } + ver.Extra = extra + } + } + } + // skip referenced storage transaction + if ver.Asset.String() == common.XINKernelAssetId && len(ver.Extra) > mc.ExtraSizeGeneralLimit { + h, _ := crypto.HashFromString(hash) + return nil, &h, nil + } + + asset, err := common.SafeReadAssetUntilSufficient(ctx, ver.Asset.String()) + if err != nil { + panic(err) + } + outputs, err := node.store.ListUserOutputsByHashAndState(ctx, uid, hash, state) + if err != nil { + panic(err) + } + if len(outputs) == 0 { + return nil, nil, fmt.Errorf("unreceived reference %s", hash) + } + for _, o := range outputs { + o.Asset = *asset + } + return outputs, nil, nil +} + +// be used to refund by mtg without fee +// be used to create prepare call by observer with fee from payer (isolatedFee = true) +// be used to create post call by observer with fee to calculate rest SOL +func (node *Node) GetSystemCallRelatedAsset(ctx context.Context, os []*store.UserOutput) []*ReferencedTxAsset { + am := make(map[string]*ReferencedTxAsset) + for _, output := range os { + logger.Printf("node.GetReferencedTxAsset() => %v", output) + amt := decimal.RequireFromString(output.Amount) + isSolAsset := output.ChainId == solanaApp.SolanaChainBase + address := output.Asset.AssetKey + if !isSolAsset { + da, err := node.store.ReadDeployedAsset(ctx, output.AssetId) + if err != nil || da == nil { + panic(fmt.Errorf("store.ReadDeployedAsset(%s) => %v %v", output.AssetId, da, err)) + } + address = da.Address + } + ra := &ReferencedTxAsset{ + Solana: isSolAsset, + Address: address, + Decimal: output.Asset.Precision, + Amount: amt, + AssetId: output.AssetId, + ChainId: output.Asset.ChainID, + Fee: false, + } + if old := am[output.AssetId]; old != nil { + ra.Amount = ra.Amount.Add(old.Amount) + } + am[output.AssetId] = ra + } + var assets []*ReferencedTxAsset + for _, a := range am { + logger.Printf("node.GetSystemCallRelatedAsset() => %v", a) + if !a.Amount.IsPositive() { + panic(a.AssetId) + } + assets = append(assets, a) + } + return assets +} + +// should only return error when no valid fees found +func (node *Node) getSystemCallFeeFromXIN(ctx context.Context, call *store.SystemCall) (*store.UserOutput, error) { + req, err := node.store.ReadRequestByHash(ctx, call.RequestHash) + if err != nil { + panic(err) + } + extra := req.ExtraBytes() + if len(extra) != 41 { + return nil, nil + } + feeId := uuid.Must(uuid.FromBytes(extra[25:])).String() + + var fee *store.FeeInfo + fee, err = node.store.ReadFeeInfoById(ctx, feeId) + logger.Printf("store.ReadFeeInfoById(%s) => %v %v", feeId, fee, err) + if err != nil { + panic(err) + } + if fee == nil { // TODO check fee timestamp against the call timestamp not too old + return nil, fmt.Errorf("invalid fee id: %s", feeId) + } + + ratio := decimal.RequireFromString(fee.Ratio) + plan, err := node.store.ReadLatestOperationParams(ctx, req.CreatedAt) + if err != nil { + panic(err) + } + + if req.Amount.Compare(plan.OperationPriceAmount) <= 0 { + return nil, nil + } + feeOnXIN := req.Amount.Sub(plan.OperationPriceAmount) + feeOnSol := feeOnXIN.Mul(ratio).RoundCeil(8).String() + + asset, err := common.SafeReadAssetUntilSufficient(ctx, common.SafeSolanaChainId) + if err != nil { + panic(err) + } + + return &store.UserOutput{ + OutputId: req.Id, + UserId: call.UserIdFromPublicPath(), + TransactionHash: req.MixinHash.String(), + OutputIndex: req.MixinIndex, + AssetId: common.SafeSolanaChainId, + ChainId: common.SafeSolanaChainId, + Amount: feeOnSol, + State: common.RequestStateInitial, + CreatedAt: req.CreatedAt, + UpdatedAt: req.CreatedAt, + + Asset: *asset, + }, nil +} + +func (node *Node) getPostProcessCall(ctx context.Context, req *store.Request, call *store.SystemCall, data []byte) (*store.SystemCall, error) { + if call.Type != store.CallTypeMain || len(data) == 0 { + return nil, nil + } + + post, tx, err := node.getSubSystemCallFromExtra(ctx, req, data) + if err != nil { + return nil, err + } + post.Superior = call.RequestId + post.Type = store.CallTypePostProcess + post.Public = call.Public + post.State = common.RequestStatePending + + user, err := node.store.ReadUser(ctx, call.UserIdFromPublicPath()) + if err != nil { + panic(err) + } + if user == nil { + return nil, fmt.Errorf("store.ReadUser(%s) => nil", call.UserIdFromPublicPath()) + } + mtgDeposit := solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry) + err = node.VerifySubSystemCall(ctx, tx, mtgDeposit, solana.MustPublicKeyFromBase58(user.ChainAddress)) + logger.Printf("node.VerifySubSystemCall(%s) => %v", user.ChainAddress, err) + if err != nil { + return nil, err + } + + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + if err != nil { + panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", call.RequestId, err)) + } + ras := node.GetSystemCallRelatedAsset(ctx, os) + err = node.comparePostCallWithSolanaTx(ctx, ras, tx, call.Hash.String, user.ChainAddress) + logger.Printf("node.comparePostCallWithSolanaTx(%s %s) => %v", call.Hash.String, user.ChainAddress, err) + if err != nil { + return nil, err + } + return post, nil +} + +func (node *Node) getSubSystemCallFromExtra(ctx context.Context, req *store.Request, data []byte) (*store.SystemCall, *solana.Transaction, error) { + if len(data) < 16 { + return nil, nil, fmt.Errorf("invalid data length: %d", len(data)) + } + id, raw := uuid.Must(uuid.FromBytes(data[:16])).String(), data[16:] + return node.buildSystemCallFromBytes(ctx, req, id, raw, true) +} + +// should only return error when fail to parse nonce advance instruction; +// without fields of superior, type, public, skip_postprocess +func (node *Node) buildSystemCallFromBytes(ctx context.Context, req *store.Request, id string, raw []byte, withdrawn bool) (*store.SystemCall, *solana.Transaction, error) { + tx, err := solana.TransactionFromBytes(raw) + logger.Printf("solana.TransactionFromBytes(%x) => %v %v", raw, tx, err) + if err != nil { + return nil, nil, err + } + err = node.processTransactionWithAddressLookups(ctx, tx) + if err != nil { + panic(err) + } + advance, err := solanaApp.NonceAccountFromTx(tx) + logger.Printf("solana.NonceAccountFromTx() => %v %v", advance, err) + if err != nil { + return nil, nil, err + } + msg, err := tx.Message.MarshalBinary() + if err != nil { + panic(err) + } + call := &store.SystemCall{ + RequestId: id, + RequestHash: req.MixinHash.String(), + NonceAccount: advance.GetNonceAccount().PublicKey.String(), + MessageHash: crypto.Sha256Hash(msg).String(), + Raw: tx.MustToBase64(), + State: common.RequestStateInitial, + CreatedAt: req.CreatedAt, + UpdatedAt: req.CreatedAt, + RequestSignerAt: sql.NullTime{Valid: true, Time: req.CreatedAt}, + } + if withdrawn { + call.WithdrawalTraces = sql.NullString{Valid: true, String: ""} + } + return call, tx, nil +} + +func (node *Node) checkUserSystemCall(ctx context.Context, tx *solana.Transaction) error { + if common.CheckTestEnvironment(ctx) { + return nil + } + + // ensure the transaction is signed by fee payer + if !tx.IsSigner(node.SolanaPayer()) { + return fmt.Errorf("tx.IsSigner(payer) => %t", false) + } + + // make sure fee payer is only used for the first nonce advance transaction + index, err := solanaApp.GetSignatureIndexOfAccount(*tx, node.SolanaPayer()) + if err != nil { + return err + } + for i, ins := range tx.Message.Instructions[1:] { + if slices.Contains(ins.Accounts, uint16(index)) { + return fmt.Errorf("invalid instruction: %d %v", i+1, ins) + } + } + return nil +} + +func (node *Node) comparePrepareCallWithSolanaTx(tx *solana.Transaction, as []*ReferencedTxAsset) error { + changes := make(map[string]*solanaApp.Transfer) + for _, ix := range tx.Message.Instructions { + transfer := solanaApp.ExtractInitialTransfersFromInstruction(&tx.Message, ix) + if transfer == nil || transfer.Sender == node.SolanaPayer().String() { + continue + } + key := transfer.TokenAddress + old := changes[key] + if old != nil { + changes[key].Value = new(big.Int).Add(old.Value, transfer.Value) + continue + } + changes[key] = transfer + } + + assets := make(map[string]*ReferencedTxAsset, len(as)) + for _, a := range as { + if assets[a.Address] != nil { + assets[a.Address].Amount = assets[a.Address].Amount.Add(a.Amount) + continue + } + assets[a.Address] = a + } + for addr, change := range changes { + a := assets[addr] + if a == nil { + return fmt.Errorf("invalid missed referenced asset: %v", a) + } + expected := a.Amount.Mul(decimal.New(1, int32(a.Decimal))).BigInt() + if expected.Cmp(change.Value) != 0 { + return fmt.Errorf("invalid referenced asset: %s %s %s", a.AssetId, change.Value.String(), expected.String()) + } + } + return nil +} + +func (node *Node) comparePostCallWithSolanaTx(ctx context.Context, as []*ReferencedTxAsset, tx *solana.Transaction, signature, user string) error { + rpcTx, err := node.solana.RPCGetTransaction(ctx, signature) + if err != nil || rpcTx == nil { + panic(fmt.Errorf("solana.RPCGetTransaction(%s) => %v %v", signature, rpcTx, err)) + } + utx, err := rpcTx.Transaction.GetTransaction() + if err != nil { + panic(err) + } + err = node.processTransactionWithAddressLookups(ctx, utx) + if err != nil { + panic(err) + } + + assets := make(map[string]*ReferencedTxAsset) + for _, a := range as { + if assets[a.Address] != nil { + assets[a.Address].Amount = assets[a.Address].Amount.Add(a.Amount) + continue + } + assets[a.Address] = a + } + cs := node.buildUserBalanceChangesFromMeta(ctx, utx, rpcTx.Meta, solana.MPK(user)) + for address, change := range cs { + old := assets[address] + if old != nil { + assets[address].Amount = assets[address].Amount.Add(change.Amount) + continue + } + if !change.Amount.IsNegative() { + continue + } + assets[address] = &ReferencedTxAsset{ + Address: address, + Decimal: int(change.Decimals), + Amount: change.Amount, + } + } + + changes := make(map[string]*solanaApp.Transfer) + for _, ix := range tx.Message.Instructions { + transfer := solanaApp.ExtractInitialTransfersFromInstruction(&tx.Message, ix) + if transfer == nil || transfer.Sender == node.SolanaPayer().String() { + continue + } + key := transfer.TokenAddress + old := changes[key] + if old != nil { + changes[key].Value = new(big.Int).Add(old.Value, transfer.Value) + continue + } + changes[key] = transfer + } + for address, c := range changes { + old := assets[address] + if old == nil { + return fmt.Errorf("invalid missed user balance change: %s", address) + } + ea := old.Amount.Mul(decimal.New(1, int32(old.Decimal))).BigInt() + if ea.Cmp(c.Value) != 0 { + return fmt.Errorf("invalid user balance change: %s %s %s", address, c.Value.String(), ea.String()) + } + } + return nil +} + +func (node *Node) compareDepositCallWithSolanaTx(ctx context.Context, tx *solana.Transaction, signature, user string) error { + rpcTx, err := node.solana.RPCGetTransaction(ctx, signature) + if err != nil || rpcTx == nil { + panic(fmt.Errorf("solana.RPCGetTransaction(%s) => %v %v", signature, rpcTx, err)) + } + dtx, err := rpcTx.Transaction.GetTransaction() + if err != nil { + panic(err) + } + err = node.checkCreatedAtaUntilSufficient(ctx, dtx) + if err != nil { + panic(err) + } + err = node.processTransactionWithAddressLookups(ctx, tx) + if err != nil { + panic(err) + } + transfers, err := solanaApp.ExtractTransfersFromTransaction(ctx, dtx, rpcTx.Meta, nil) + if err != nil { + panic(err) + } + expectedChanges, err := node.parseSolanaBlockBalanceChanges(ctx, transfers) + if err != nil { + panic(err) + } + + transfers = nil + for _, ix := range tx.Message.Instructions { + if transfer := solanaApp.ExtractInitialTransfersFromInstruction(&tx.Message, ix); transfer != nil { + transfers = append(transfers, transfer) + } + } + actualChanges := make(map[string]*big.Int) + for _, t := range transfers { + if t.Sender != user || t.Receiver != node.getMTGAddress(ctx).String() { + continue + } + key := fmt.Sprintf("%s:%s", t.Receiver, t.TokenAddress) + total := actualChanges[key] + if total != nil { + actualChanges[key] = new(big.Int).Add(total, t.Value) + } else { + actualChanges[key] = t.Value + } + } + for key, actual := range actualChanges { + expected := expectedChanges[key] + if expected == nil { + return fmt.Errorf("non-existed deposit: %s %s %s", signature, key, tx.MustToBase64()) + } + if expected.Cmp(actual) != 0 { + return fmt.Errorf("invalid deposit: %s %s %s %s %s", signature, key, expected.String(), actual.String(), tx.MustToBase64()) + } + } + return nil +} + +func attachSystemCall(extra []byte, cid string, raw []byte) []byte { + extra = append(extra, uuid.Must(uuid.FromString(cid)).Bytes()...) + extra = append(extra, raw...) + return extra +} diff --git a/computer/test.go b/computer/test.go new file mode 100644 index 00000000..0347fcfe --- /dev/null +++ b/computer/test.go @@ -0,0 +1,212 @@ +package computer + +import ( + "context" + "encoding/hex" + "encoding/json" + "sync" + "time" + + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/multi-party-sig/pkg/party" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/messenger" + "github.com/MixinNetwork/safe/mtg" + "github.com/gofrs/uuid/v5" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +type partyContextTyp string + +const partyContextKey = partyContextTyp("party") + +var outputReferences = make(map[string][]crypto.Hash) + +func writeOutputReferences(outputId string, references []crypto.Hash) { + outputReferences[outputId] = references +} + +func readOutputReferences(outputId string) []crypto.Hash { + return outputReferences[outputId] +} + +func TestProcessOutput(ctx context.Context, require *require.Assertions, nodes []*Node, out *mtg.Action, sessionId string) *common.Operation { + out.TestAttachActionToGroup(nodes[0].group) + network := nodes[0].network.(*testNetwork) + for i := range 4 { + data := common.MarshalJSONOrPanic(out) + network.mtgChannel(nodes[i].id) <- data + } + + var op *common.Operation + for _, node := range nodes { + op = testWaitOperation(ctx, node, sessionId) + logger.Verbosef("testWaitOperation(%s, %s) => %v\n", node.id, sessionId, op) + } + return op +} + +func testWaitOperation(ctx context.Context, node *Node, sessionId string) *common.Operation { + timeout := time.Now().Add(time.Minute * 4) + for ; time.Now().Before(timeout); time.Sleep(3 * time.Second) { + val, err := node.store.ReadProperty(ctx, "SIGNER:"+sessionId) + if err != nil { + panic(err) + } + if val == "" { + continue + } + data, err := hex.DecodeString(val) + if err != nil { + panic(err) + } + op := decodeOperation(data) + if op != nil { + return op + } + } + return nil +} + +type testNetwork struct { + parties party.IDSlice + msgChannels map[party.ID]chan []byte + mtgChannels map[party.ID]chan []byte + mtx sync.Mutex +} + +func newTestNetwork(parties party.IDSlice) *testNetwork { + n := &testNetwork{ + parties: parties, + msgChannels: make(map[party.ID]chan []byte, 2*len(parties)), + mtgChannels: make(map[party.ID]chan []byte, 2*len(parties)), + } + N := len(n.parties) + for _, id := range n.parties { + n.msgChannels[id] = make(chan []byte, N*N) + n.mtgChannels[id] = make(chan []byte, N*N) + } + return n +} + +func (n *testNetwork) mtgLoop(ctx context.Context, node *Node) { + filter := make(map[string]bool) + loop := n.mtgChannels[node.id] + logger.Printf("loop: %s %d", node.id, len(loop)) + for mob := range loop { + k := hex.EncodeToString(mob) + if filter[k] { + continue + } + var out mtg.Action + _ = json.Unmarshal(mob, &out) + out.TestAttachActionToGroup(node.group) + ts, asset := node.ProcessOutput(ctx, &out) + if asset != "" { + panic(asset) + } + for _, t := range ts { + op := decodeOperation([]byte(t.Memo)) + memo := mtg.EncodeMixinExtraBase64(node.conf.AppId, []byte(t.Memo)) + err := node.store.WriteProperty(ctx, "SIGNER:"+op.Id, hex.EncodeToString([]byte(memo))) + if err != nil { + panic(err) + } + } + filter[k] = true + } +} + +func (node *Node) mtgQueueTestOutput(ctx context.Context, memo []byte) error { + hash := []byte{byte(node.Index())} + hash = append(hash, memo...) + out := &mtg.Action{ + UnifiedOutput: mtg.UnifiedOutput{ + OutputId: uuid.Must(uuid.NewV4()).String(), + TransactionHash: crypto.Sha256Hash(hash).String(), + AppId: node.conf.AppId, + Amount: decimal.NewFromInt(1), + Senders: []string{string(node.id)}, + AssetId: node.conf.AssetId, + SequencerCreatedAt: time.Now().UTC(), + }, + } + out.Extra = mtg.EncodeMixinExtraBase64(node.conf.AppId, memo) + out.Extra = hex.EncodeToString([]byte(out.Extra)) + data := common.MarshalJSONOrPanic(out) + network := node.network.(*testNetwork) + return network.QueueMTGOutput(ctx, data) +} + +func (n *testNetwork) ReceiveMessage(ctx context.Context) (*messenger.MixinMessage, error) { + id := ctx.Value(partyContextKey).(string) + msb := <-n.msgChannel(party.ID(id)) + _, msg, _ := unmarshalSessionMessage(msb) + return &messenger.MixinMessage{ + Peer: string(msg.From), + Data: msb, + CreatedAt: time.Now().UTC(), + }, nil +} + +func (n *testNetwork) QueueMessage(ctx context.Context, receiver string, b []byte) error { + sessionId, msg, err := unmarshalSessionMessage(b) + logger.Verbosef("test.QueueMessage(%s) => %x %v %v", receiver, sessionId, msg, err) + if err != nil { + return err + } + n.msgChannel(party.ID(receiver)) <- marshalSessionMessage(sessionId, msg) + logger.Verbosef("test.Send(%s) => %x %v %v", receiver, sessionId, msg, err) + return nil +} + +func (n *testNetwork) QueueMTGOutput(ctx context.Context, b []byte) error { + n.mtx.Lock() + defer n.mtx.Unlock() + + for _, c := range n.mtgChannels { + c <- b + } + return nil +} + +func (n *testNetwork) mtgChannel(id party.ID) chan []byte { + n.mtx.Lock() + defer n.mtx.Unlock() + + return n.mtgChannels[id] +} + +func (n *testNetwork) msgChannel(id party.ID) chan []byte { + n.mtx.Lock() + defer n.mtx.Unlock() + + return n.msgChannels[id] +} + +func getTestSystemConfirmCallMessage(signature string) string { + if signature == "2tPHv7kbUeHRWHgVKKddQqXnjDhuX84kTyCvRy1BmCM4m4Fkq4vJmNAz8A7fXqckrSNRTAKuPmAPWnzr5T7eCChb" { + return "4d57022c484aebdb7d4472c16740f7e8c4f9047b41cbcf05a9d517558bc276c7" + } + if signature == "5s3UBMymdgDHwYvuaRdq9SLq94wj5xAgYEsDDB7TQwwuLy1TTYcSf6rF4f2fDfF7PnA9U75run6r1pKm9K1nusCR" { + return "bf1648ad15341bc4225e08e1c6842df68bf80f309764ec32327deab8e2743167" + } + return "" +} + +var ( + testFROSTKeys1 = map[party.ID]string{ + "member-id-0": "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b;0001000b6d656d6265722d69642d3000020020fe4584dcd16c51736b64e329ef2fd51b4f1d98ee833cdc96ace16398fd243f080020fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b000000b9a46b6d656d6265722d69642d305820cd5b764c011927f356938f5ebdd5f825c6f07e72f07a67ab7da1b8ec291de8d56b6d656d6265722d69642d315820d059874222f3d7a00a98da49fe388141717541f7d6ba7b0baf01af63c03510796b6d656d6265722d69642d325820e8b3ba906961e5e2ab66405d7105c2b2c19695a34ae77e229dabc2ef59ec71386b6d656d6265722d69642d33582090115b147e3977a8d44f58d40cdece998bd4b204b02ad91da9756cfff9969298", + "member-id-1": "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b;0001000b6d656d6265722d69642d3100020020c6ec44a22c007a43d7518ac10669424693b159534fa32dbe872a5169c8f7210c0020fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b000000b9a46b6d656d6265722d69642d305820cd5b764c011927f356938f5ebdd5f825c6f07e72f07a67ab7da1b8ec291de8d56b6d656d6265722d69642d315820d059874222f3d7a00a98da49fe388141717541f7d6ba7b0baf01af63c03510796b6d656d6265722d69642d325820e8b3ba906961e5e2ab66405d7105c2b2c19695a34ae77e229dabc2ef59ec71386b6d656d6265722d69642d33582090115b147e3977a8d44f58d40cdece998bd4b204b02ad91da9756cfff9969298", + "member-id-2": "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b;0001000b6d656d6265722d69642d3200020020e6543b705f73a02061f97cdcc45a47934dc5ee9f7a9f382d417eb74128ea100f0020fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b000000b9a46b6d656d6265722d69642d305820cd5b764c011927f356938f5ebdd5f825c6f07e72f07a67ab7da1b8ec291de8d56b6d656d6265722d69642d315820d059874222f3d7a00a98da49fe388141717541f7d6ba7b0baf01af63c03510796b6d656d6265722d69642d325820e8b3ba906961e5e2ab66405d7105c2b2c19695a34ae77e229dabc2ef59ec71386b6d656d6265722d69642d33582090115b147e3977a8d44f58d40cdece998bd4b204b02ad91da9756cfff9969298", + "member-id-3": "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b;0001000b6d656d6265722d69642d330002002071aa71e94f63b2b232bec3d74a0b05ee7d5857d40531fde3d8dc96211dfc0b010020fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b000000b9a46b6d656d6265722d69642d305820cd5b764c011927f356938f5ebdd5f825c6f07e72f07a67ab7da1b8ec291de8d56b6d656d6265722d69642d315820d059874222f3d7a00a98da49fe388141717541f7d6ba7b0baf01af63c03510796b6d656d6265722d69642d325820e8b3ba906961e5e2ab66405d7105c2b2c19695a34ae77e229dabc2ef59ec71386b6d656d6265722d69642d33582090115b147e3977a8d44f58d40cdece998bd4b204b02ad91da9756cfff9969298", + } + testFROSTKeys2 = map[party.ID]string{ + "member-id-0": "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295;0001000b6d656d6265722d69642d3000020020d9eb970a228a541283bf1378a94cde85179c574c92e6dfdcb510a274921c260800204375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca850029500205030d34432d9323e0bb0d4f83d26565f78ea5bdde762050ea7962c37ff7eb02400b9a46b6d656d6265722d69642d3058200c936db9dd8f705ab3395b21f97118ddaa58ded4ec63367bda2b258fbba0f37e6b6d656d6265722d69642d3158202a63eb53d93f05be548f7e483b66981fe98285407423a239a11b366b290390736b6d656d6265722d69642d325820399b5242e0b9bc8c8e793e63c538c1f37a45110bd0ccd3bf3b7e7a9644bfd7f66b6d656d6265722d69642d335820b57cdb2507ce3b7eef97e6c14ab0ebfa7a6b42dab0de7573e4db5d3fa5bcae37", + "member-id-1": "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295;0001000b6d656d6265722d69642d310002002024d226e5c362ec4a3d670e389b5a7af77acb3e3c73ed8c171706cab242e6260f00204375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca850029500205030d34432d9323e0bb0d4f83d26565f78ea5bdde762050ea7962c37ff7eb02400b9a46b6d656d6265722d69642d3058200c936db9dd8f705ab3395b21f97118ddaa58ded4ec63367bda2b258fbba0f37e6b6d656d6265722d69642d3158202a63eb53d93f05be548f7e483b66981fe98285407423a239a11b366b290390736b6d656d6265722d69642d325820399b5242e0b9bc8c8e793e63c538c1f37a45110bd0ccd3bf3b7e7a9644bfd7f66b6d656d6265722d69642d335820b57cdb2507ce3b7eef97e6c14ab0ebfa7a6b42dab0de7573e4db5d3fa5bcae37", + "member-id-2": "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295;0001000b6d656d6265722d69642d3200020020a15b5382d54a354e943e5fe090f0ee260c64a6ddd4f85a9ee7c6a608893d780800204375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca850029500205030d34432d9323e0bb0d4f83d26565f78ea5bdde762050ea7962c37ff7eb02400b9a46b6d656d6265722d69642d3058200c936db9dd8f705ab3395b21f97118ddaa58ded4ec63367bda2b258fbba0f37e6b6d656d6265722d69642d3158202a63eb53d93f05be548f7e483b66981fe98285407423a239a11b366b290390736b6d656d6265722d69642d325820399b5242e0b9bc8c8e793e63c538c1f37a45110bd0ccd3bf3b7e7a9644bfd7f66b6d656d6265722d69642d335820b57cdb2507ce3b7eef97e6c14ab0ebfa7a6b42dab0de7573e4db5d3fa5bcae37", + "member-id-3": "4375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca8500295;0001000b6d656d6265722d69642d33000200203d5c133f71a541745ee2fd1369081b29cb658e30b7084a712753387665221a0400204375bcd5726aadfdd159135441bbe659c705b37025c5c12854e9906ca850029500205030d34432d9323e0bb0d4f83d26565f78ea5bdde762050ea7962c37ff7eb02400b9a46b6d656d6265722d69642d3058200c936db9dd8f705ab3395b21f97118ddaa58ded4ec63367bda2b258fbba0f37e6b6d656d6265722d69642d3158202a63eb53d93f05be548f7e483b66981fe98285407423a239a11b366b290390736b6d656d6265722d69642d325820399b5242e0b9bc8c8e793e63c538c1f37a45110bd0ccd3bf3b7e7a9644bfd7f66b6d656d6265722d69642d335820b57cdb2507ce3b7eef97e6c14ab0ebfa7a6b42dab0de7573e4db5d3fa5bcae37", + } +) diff --git a/computer/transaction.go b/computer/transaction.go new file mode 100644 index 00000000..11328f7f --- /dev/null +++ b/computer/transaction.go @@ -0,0 +1,149 @@ +package computer + +import ( + "context" + "encoding/base64" + "encoding/hex" + "fmt" + + "github.com/MixinNetwork/bot-api-go-client/v3" + mc "github.com/MixinNetwork/mixin/common" + "github.com/MixinNetwork/mixin/crypto" + "github.com/MixinNetwork/mixin/logger" + "github.com/MixinNetwork/safe/common" + "github.com/MixinNetwork/safe/mtg" + "github.com/shopspring/decimal" +) + +func (node *Node) readStorageExtraFromObserver(ctx context.Context, ref crypto.Hash) []byte { + if common.CheckTestEnvironment(ctx) { + val, err := node.store.ReadProperty(ctx, ref.String()) + if err != nil { + panic(ref.String()) + } + raw, err := base64.RawURLEncoding.DecodeString(val) + if err != nil { + panic(ref.String()) + } + return raw + } + + ver, err := node.group.ReadKernelTransactionUntilSufficient(ctx, ref.String()) + if err != nil { + panic(ref.String()) + } + + return ver.Extra +} + +func (node *Node) checkTransaction(ctx context.Context, act *mtg.Action, assetId string, receivers []string, threshold int, destination, tag, amount string, memo []byte, traceId string) string { + if common.CheckTestEnvironment(ctx) { + v := common.MarshalJSONOrPanic(map[string]any{ + "asset_id": assetId, + "amount": amount, + "receivers": receivers, + "threshold": threshold, + "destination": destination, + "tag": tag, + "memo": hex.EncodeToString(memo), + }) + err := node.store.WriteProperty(ctx, traceId, string(v)) + if err != nil { + panic(err) + } + } else { + balance := act.CheckAssetBalanceAt(ctx, assetId) + logger.Printf("group.CheckAssetBalanceAt(%s, %d) => %s %s %s", assetId, act.Sequence, traceId, amount, balance) + amt := decimal.RequireFromString(amount) + if balance.Cmp(amt) < 0 { + return "" + } + } + + nextId := common.UniqueId(node.group.GenesisId(), traceId) + logger.Printf("node.checkTransaction(%s) => %s", traceId, nextId) + return nextId +} + +func (node *Node) buildWithdrawalTransaction(ctx context.Context, act *mtg.Action, assetId, amount string, memo []byte, destination, tag, traceId string) *mtg.Transaction { + logger.Printf("node.buildTransactionWithReferences(%s, %s, %x, %s, %s, %s)", assetId, amount, memo, destination, tag, traceId) + traceId = node.checkTransaction(ctx, act, assetId, nil, 0, destination, tag, amount, memo, traceId) + if traceId == "" { + return nil + } + + return act.BuildWithdrawTransaction(ctx, traceId, assetId, amount, string(memo), destination, tag) +} + +func (node *Node) buildTransaction(ctx context.Context, act *mtg.Action, opponentAppId, assetId string, receivers []string, threshold int, amount string, memo []byte, traceId string) *mtg.Transaction { + logger.Printf("node.buildTransaction(%s, %s, %v, %d, %s, %x, %s)", opponentAppId, assetId, receivers, threshold, amount, memo, traceId) + return node.buildTransactionWithReferences(ctx, act, opponentAppId, assetId, receivers, threshold, amount, memo, traceId, crypto.Hash{}) +} + +func (node *Node) buildTransactionWithReferences(ctx context.Context, act *mtg.Action, opponentAppId, assetId string, receivers []string, threshold int, amount string, memo []byte, traceId string, tx crypto.Hash) *mtg.Transaction { + logger.Printf("node.buildTransactionWithReferences(%s, %v, %d, %s, %x, %s, %s)", assetId, receivers, threshold, amount, memo, traceId, tx) + traceId = node.checkTransaction(ctx, act, assetId, receivers, threshold, "", "", amount, memo, traceId) + if traceId == "" { + return nil + } + + if tx.HasValue() { + return act.BuildTransactionWithReference(ctx, traceId, opponentAppId, assetId, amount, string(memo), receivers, threshold, tx) + } + return act.BuildTransaction(ctx, traceId, opponentAppId, assetId, amount, string(memo), receivers, threshold) +} + +func (node *Node) sendObserverTransactionToGroup(ctx context.Context, op *common.Operation, references []crypto.Hash) error { + logger.Printf("node.sendObserverTransactionToGroup(%v)", op) + extra := encodeOperation(op) + extra = node.signObserverExtra(extra) + + traceId := fmt.Sprintf("SESSION:%s:OBSERVER:%s", op.Id, string(node.id)) + return node.sendTransactionToGroupUntilSufficient(ctx, extra, "0.00000001", bot.XINAssetId, traceId, references) +} + +func (node *Node) sendSignerTransactionToGroup(ctx context.Context, traceId string, op *common.Operation, references []crypto.Hash) error { + logger.Printf("node.sendSignerTransactionToGroup(%s %v)", node.id, op) + extra := encodeOperation(op) + + return node.sendTransactionToGroupUntilSufficient(ctx, extra, "1", node.conf.AssetId, traceId, references) +} + +func (node *Node) sendTransactionToGroupUntilSufficient(ctx context.Context, memo []byte, amount, assetId, traceId string, references []crypto.Hash) error { + receivers := node.GetMembers() + threshold := node.conf.MTG.Genesis.Threshold + amt := decimal.RequireFromString(amount) + traceId = common.UniqueId(traceId, fmt.Sprintf("MTG:%v:%d", receivers, threshold)) + + if common.CheckTestEnvironment(ctx) { + return node.mtgQueueTestOutput(ctx, memo) + } + m := mtg.EncodeMixinExtraBase64(node.conf.AppId, memo) + if len([]byte(m)) <= mc.ExtraSizeGeneralLimit { + _, err := common.SendTransactionUntilSufficient(ctx, node.mixin, []string{node.mixin.ClientID}, 1, receivers, threshold, amt, traceId, assetId, m, references, node.conf.MTG.App.SpendPrivateKey) + logger.Printf("node.SendTransactionUntilSufficient(%s) => %v", traceId, err) + return err + } + + _, err := common.WriteStorageUntilSufficient(ctx, node.mixin, []*bot.TransactionRecipient{ + { + MixAddress: bot.NewUUIDMixAddress(node.conf.MTG.Genesis.Members, byte(node.conf.MTG.Genesis.Threshold)), + Amount: amount, + }, + }, []byte(m), traceId, *node.SafeUser()) + logger.Printf("node.WriteStorageUntilSufficient(%s) => %v", traceId, err) + return err +} + +func encodeOperation(op *common.Operation) []byte { + extra := []byte{op.Type} + extra = append(extra, op.Extra...) + return extra +} + +func decodeOperation(extra []byte) *common.Operation { + return &common.Operation{ + Type: extra[0], + Extra: extra[1:], + } +} diff --git a/config/example.toml b/config/example.toml index 78a56c7a..46fd2595 100644 --- a/config/example.toml +++ b/config/example.toml @@ -144,6 +144,52 @@ session-private-key = "" server-public-key = "" spend-private-key = "" + +[computer] +# the id represents actions and outptus for computer group +app-id = "a7376114-5db3-4822-bd3c-26416b57da1b" +store-dir = "/tmp/safe/computer" +# the mixin messenger group conversation id for computer communication +messenger-conversation-id = "" +# the mixin messenger group for monitor messages +monitor-conversation-id = "" +timestamp = 1721930640000000000 +# the mpc threshold is recommended to be 2/3 of the mtg members count +threshold = 2 +asset-id = "a946936b-1b52-3e02-aec6-4fbccf284d5f" +observer-id = "c91eb626-eb89-4fbd-ae21-76f0bd763da5" +observer-spend-public-key = "5079d10fd5318aff7dea72de4173be2cc1a0b7841ab9ddc804ac3041e707829c" +operation-price-asset-id = "c94ac88f-4671-3976-b60a-09064f1811e8" +operation-price-amount = "0.001" +# the number of base mpc key +mpc-key-number = 1 +mixin-messenger-api="https://api.mixin.one" +mixin-rpc = "https://kernel.mixin.dev" +solana-rpc = "https://omniscient-omniscient-reel.solana-mainnet.quiknode.pro/ba89021c3319102fdeaaba182d89b8039280ef21" +# solana private key to sign and send transaction +solana-key = "56HtVW5YQ9Xi8MTeQFAWdSuzV17mrDAr1AUCYzTdx36VLvsodA89eSuZd6axrufzo4tyoUNdgjDpm4fnLJLRcXmF" +solana-deposit-entry = "HT7X7p5XPERge4ZShYALRKzkgxua1fW7rVMfwChRrG9V" + + +[computer.mtg.genesis] +members = [ + "member-id-0", + "member-id-1", + "member-id-2", + "member-id-3", +] +# the mtg threshold must not be smaller than the mpc threshold +threshold = 3 +epoch = 15903300 + +[computer.mtg.app] +app-id = "member-id-0" +session-id = "194ac88f-4671-3976-b60a-09064f1811e8" +session-private-key = "9b727c4954c0f29d9e76258a97f45c4c32a748c3536bee03e486a17d7ba59409" +server-public-key = "849bd198be846981839a5e5bef929cf8b71543ec31d5ff3cee4f272656a921d5" +spend-private-key = "6004d10dab1c2ee8fb512399eeb9aa8ce2112eee07c20df780fb76d840cbcd0e" + + [dev] # set a listen port to enable go pprof profile-port = 12345 diff --git a/config/reader.go b/config/reader.go index 3bd13e07..c2821762 100644 --- a/config/reader.go +++ b/config/reader.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/MixinNetwork/safe/computer" "github.com/MixinNetwork/safe/keeper" "github.com/MixinNetwork/safe/observer" "github.com/MixinNetwork/safe/signer" @@ -19,6 +20,7 @@ type Configuration struct { Signer *signer.Configuration `toml:"signer"` Keeper *keeper.Configuration `toml:"keeper"` Observer *observer.Configuration `toml:"observer"` + Computer *computer.Configuration `toml:"computer"` Dev *DevConfig `toml:"dev"` } @@ -39,8 +41,6 @@ func ReadConfiguration(path, role string) (*Configuration, error) { handleDevConfig(conf.Dev) conf.checkMainnet(role) conf.checkTestnet(role) - sort.Strings(conf.Keeper.MTG.Genesis.Members) - sort.Strings(conf.Signer.MTG.Genesis.Members) return &conf, nil } @@ -49,6 +49,7 @@ func (c *Configuration) checkMainnet(role string) { case "signer": case "keeper": case "observer": + case "computer": default: panic(role) } @@ -95,23 +96,24 @@ func (c *Configuration) checkMainnet(role string) { } keepers := append(signers, "c91eb626-eb89-4fbd-ae21-76f0bd763da5") - s := c.Signer if role == "signer" { + s := c.Signer assert(s.AppId, SignerAppId, "signer.app-id") assert(s.KeeperAppId, KeeperAppId, "signer.keeper-app-id") assert(s.AssetId, SignerToken, "signer.asset-id") assert(s.KeeperAssetId, KeeperToken, "signer.keeper-asset-id") - } - if role == "signer" || role == "keeper" { + assert(s.MTG.Genesis.Epoch, uint64(15903300), "signer.genesis.epoch") assert(s.MTG.Genesis.Threshold, int(19), "signer.genesis.threshold") if !slices.Equal(s.MTG.Genesis.Members, signers) { panic("signers") } + + sort.Strings(c.Signer.MTG.Genesis.Members) } - k := c.Keeper if role == "keeper" { + k := c.Keeper assert(k.AppId, KeeperAppId, "keeper.app-id") assert(k.SignerAppId, SignerAppId, "keeper.signer-app-id") assert(k.AssetId, KeeperToken, "keeper.asset-id") @@ -119,13 +121,14 @@ func (c *Configuration) checkMainnet(role string) { assert(k.PolygonFactoryAddress, "0x4D17777E0AC12C6a0d4DEF1204278cFEAe142a1E", "keeper.polygon-factory-address") assert(k.PolygonObserverDepositEntry, "0x4A2eea63775F0407E1f0d147571a46959479dE12", "keeper.polygon-observer-deposit-entry") assert(k.PolygonKeeperDepositEntry, "0x5A3A6E35038f33458c13F3b5349ee5Ae1e94a8d9", "keeper.polygon-keeper-deposity-entry") - } - if role == "keeper" || role == "observer" { + assert(k.MTG.Genesis.Epoch, uint64(15903300), "keeper.genesis.epoch") assert(k.MTG.Genesis.Threshold, int(19), "keeper.genesis.threshold") if !slices.Equal(k.MTG.Genesis.Members, keepers) { panic("keepers") } + + sort.Strings(c.Keeper.MTG.Genesis.Members) } if role == "observer" { @@ -144,6 +147,7 @@ func (c *Configuration) checkTestnet(role string) { case "signer": case "keeper": case "observer": + case "computer": default: panic(role) } @@ -164,23 +168,24 @@ func (c *Configuration) checkTestnet(role string) { } keepers := append(signers, "fcb87491-4fa0-4c2f-b387-262b63cbc112") - s := c.Signer if role == "signer" { + s := c.Signer assert(s.AppId, SignerAppId, "signer.app-id") assert(s.KeeperAppId, KeeperAppId, "signer.keeper-app-id") assert(s.AssetId, SignerToken, "signer.asset-id") assert(s.KeeperAssetId, KeeperToken, "signer.keeper-asset-id") - } - if role == "signer" || role == "keeper" { + assert(s.MTG.Genesis.Epoch, uint64(9877485), "signer.genesis.epoch") assert(s.MTG.Genesis.Threshold, int(4), "signer.genesis.threshold") if !slices.Equal(s.MTG.Genesis.Members, signers) { panic("signers") } + + sort.Strings(c.Signer.MTG.Genesis.Members) } - k := c.Keeper if role == "keeper" { + k := c.Keeper assert(k.AppId, KeeperAppId, "keeper.app-id") assert(k.SignerAppId, SignerAppId, "keeper.signer-app-id") assert(k.AssetId, KeeperToken, "keeper.asset-id") @@ -188,13 +193,14 @@ func (c *Configuration) checkTestnet(role string) { assert(k.PolygonFactoryAddress, "0x4D17777E0AC12C6a0d4DEF1204278cFEAe142a1E", "keeper.polygon-factory-address") assert(k.PolygonObserverDepositEntry, "0x9d04735aaEB73535672200950fA77C2dFC86eB21", "keeper.polygon-observer-deposit-entry") assert(k.PolygonKeeperDepositEntry, "0x11EC02748116A983deeD59235302C3139D6e8cdD", "keeper.polygon-keeper-deposity-entry") - } - if role == "keeper" || role == "observer" { + assert(k.MTG.Genesis.Epoch, uint64(9877485), "keeper.genesis.epoch") assert(k.MTG.Genesis.Threshold, int(4), "keeper.genesis.threshold") if !slices.Equal(k.MTG.Genesis.Members, keepers) { panic("keepers") } + + sort.Strings(c.Keeper.MTG.Genesis.Members) } if role == "observer" { diff --git a/go.mod b/go.mod index fe163ef3..17c35c72 100644 --- a/go.mod +++ b/go.mod @@ -7,16 +7,25 @@ require ( github.com/MixinNetwork/bot-api-go-client/v3 v3.12.1 github.com/MixinNetwork/mixin v0.18.26 github.com/MixinNetwork/multi-party-sig v0.4.1 + github.com/blocto/solana-go-sdk v1.30.0 github.com/btcsuite/btcd v0.24.2 github.com/btcsuite/btcd/btcec/v2 v2.3.4 github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil/psbt v1.1.10 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/chai2010/webp v1.4.0 + github.com/davecgh/go-spew v1.1.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 github.com/dimfeld/httptreemux/v5 v5.5.0 + github.com/disintegration/imaging v1.6.2 github.com/ethereum/go-ethereum v1.15.11 + github.com/fogleman/gg v1.3.0 github.com/fox-one/mixin-sdk-go/v2 v2.0.11 github.com/fxamacker/cbor/v2 v2.8.0 + github.com/gagliardetto/binary v0.8.0 + github.com/gagliardetto/solana-go v1.12.0 + github.com/gagliardetto/treeout v0.1.4 + github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid/v5 v5.3.2 github.com/mattn/go-sqlite3 v1.14.27 github.com/mdp/qrterminal v1.0.1 @@ -32,7 +41,10 @@ require ( github.com/MixinNetwork/go-number v0.1.1 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/aead/siphash v1.0.1 // indirect + github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect github.com/bits-and-blooms/bitset v1.22.0 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect github.com/btcsuite/btclog v1.0.0 // indirect github.com/btcsuite/btcutil v1.0.2 // indirect github.com/consensys/bavard v0.1.30 // indirect @@ -41,30 +53,42 @@ require ( github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/cronokirby/saferith v0.33.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.1 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fox-one/msgpack v1.0.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/kkdai/bstream v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/near/borsh-go v0.3.2-0.20220516180422-1ff87d108454 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/supranational/blst v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect @@ -74,9 +98,17 @@ require ( github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect + go.mongodb.org/mongo-driver v1.17.3 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/ratelimit v0.3.1 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/image v0.27.0 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/time v0.11.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 91839403..11e40723 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,16 @@ github.com/MixinNetwork/multi-party-sig v0.4.1/go.mod h1:mnZyPutnRV2+E6z3v5TpTb7 github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/blocto/solana-go-sdk v1.30.0 h1:GEh4GDjYk1lMhV/hqJDCyuDeCuc5dianbN33yxL88NU= +github.com/blocto/solana-go-sdk v1.30.0/go.mod h1:Xoyhhb3hrGpEQ5rJps5a3OgMwDpmEhrd9bgzFKkkwMs= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -51,6 +59,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= +github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/consensys/bavard v0.1.30 h1:wwAj9lSnMLFXjEclKwyhf7Oslg8EoaFz9u1QGgt0bsk= github.com/consensys/bavard v0.1.30/go.mod h1:k/zVjHHC4B+PQy1Pg7fgvG3ALicQw540Crag8qx+dZs= @@ -80,6 +90,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjY github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dimfeld/httptreemux/v5 v5.5.0 h1:p8jkiMrCuZ0CmhwYLcbNbl7DDo21fozhKHQ2PccwOFQ= github.com/dimfeld/httptreemux/v5 v5.5.0/go.mod h1:QeEylH57C0v3VO0tkKraVz9oD3Uu93CKPnTLbsidvSw= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ethereum/c-kzg-4844/v2 v2.1.1 h1:KhzBVjmURsfr1+S3k/VE35T02+AW2qU9t9gr4R6YpSo= @@ -88,6 +100,10 @@ github.com/ethereum/go-ethereum v1.15.11 h1:JK73WKeu0WC0O1eyX+mdQAVHUV+UR1a9VB/d github.com/ethereum/go-ethereum v1.15.11/go.mod h1:mf8YiHIb0GR4x4TipcvBUPxJLw1mFdmxzoDi11sDRoI= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fox-one/mixin-sdk-go/v2 v2.0.11 h1:nH8JxCZ1QJT9gWsPvdJiQlBTxUFai9nyKodToJ8XqZk= github.com/fox-one/mixin-sdk-go/v2 v2.0.11/go.mod h1:3oaTbgw3ERL7UVi5E40NenQ16EkBVV7X++brLM1uWqU= github.com/fox-one/msgpack v1.0.0 h1:atr4La29WdMPCoddlRAPK2e1yhBJ2cEFF+2X93KY5Vs= @@ -98,6 +114,12 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/solana-go v1.12.0 h1:rzsbilDPj6p+/DOPXBMLhwMZeBgeRuXjm5zQFCoXgsg= +github.com/gagliardetto/solana-go v1.12.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -113,6 +135,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -138,6 +162,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -151,24 +176,49 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/near/borsh-go v0.3.2-0.20220516180422-1ff87d108454 h1:lFN7TVecCMbCHVNfEofDqqaVsuAlkFyDmmO7EF4nXj4= +github.com/near/borsh-go v0.3.2-0.20220516180422-1ff87d108454/go.mod h1:NeMochZp7jN/pYFuxLkrZtmLqbADmnp/y1+/dL+AsyQ= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -182,6 +232,7 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgF github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -194,6 +245,9 @@ github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMT github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= +github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e h1:qGVGDR2/bXLyR498un1hvhDQPUJ/m14JBRTJz+c67Bc= +github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -209,6 +263,7 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo= github.com/supranational/blst v0.3.14/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= @@ -221,6 +276,7 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -229,20 +285,43 @@ github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvv github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= +go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -258,6 +337,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -270,6 +351,7 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= @@ -287,11 +369,15 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -305,9 +391,12 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -315,15 +404,19 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -352,13 +445,17 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index 2e5dce8a..1c51ad1b 100644 --- a/main.go +++ b/main.go @@ -418,6 +418,19 @@ func main() { }, }, }, + { + Name: "computer", + Usage: "Run the computer node", + Action: cmd.ComputerBootCmd, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: "~/.mixin/safe/config.toml", + Usage: "The configuration file path", + }, + }, + }, }, } diff --git a/mtg/group.go b/mtg/group.go index c2bd68fe..63c6f5fe 100644 --- a/mtg/group.go +++ b/mtg/group.go @@ -275,14 +275,6 @@ func (grp *Group) ListOutputsForTransaction(ctx context.Context, traceId string, return outputs } -func (grp *Group) ListOutputsByTransactionHash(ctx context.Context, hash string, sequence uint64) []*UnifiedOutput { - outputs, err := grp.store.ListOutputsByTransactionHash(ctx, hash, sequence) - if err != nil { - panic(err) - } - return outputs -} - func (grp *Group) ListUnconfirmedWithdrawalTransactions(ctx context.Context, limit int) []*Transaction { txs, err := grp.store.ListUnconfirmedWithdrawalTransactions(ctx, limit) if err != nil { diff --git a/mtg/store.go b/mtg/store.go index a0889086..30cb08be 100644 --- a/mtg/store.go +++ b/mtg/store.go @@ -267,25 +267,6 @@ func (s *SQLite3Store) ListOutputsForTransaction(ctx context.Context, traceId st return os, nil } -func (s *SQLite3Store) ListOutputsByTransactionHash(ctx context.Context, hash string, sequence uint64) ([]*UnifiedOutput, error) { - query := fmt.Sprintf("SELECT %s FROM outputs WHERE transaction_hash=? AND sequence<=? ORDER BY transaction_hash, sequence ASC", strings.Join(outputCols, ",")) - rows, err := s.db.QueryContext(ctx, query, hash, sequence) - if err != nil { - return nil, err - } - defer rows.Close() - - var os []*UnifiedOutput - for rows.Next() { - o, err := outputFromRow(rows) - if err != nil { - return nil, err - } - os = append(os, o) - } - return os, nil -} - func (s *SQLite3Store) ListOutputsForAsset(ctx context.Context, appId, assetId string, consumedUntil, sequence uint64, state SafeUtxoState, limit int) ([]*UnifiedOutput, error) { query := fmt.Sprintf("SELECT %s FROM outputs WHERE app_id=? AND asset_id=? AND state=? AND sequence>? AND sequence<=? ORDER BY app_id, asset_id, state, sequence ASC", strings.Join(outputCols, ",")) if limit > 0 {