Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions apps/solana/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,18 @@ type MintData struct {
}

func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error) {
return c.rpcGetAsset(ctx, address, rpc.CommitmentProcessed)
}

func (c *Client) RPCGetConfirmedAsset(ctx context.Context, address string) (*Asset, error) {
return c.rpcGetAsset(ctx, address, rpc.CommitmentConfirmed)
Comment on lines +103 to +104
}

func (c *Client) rpcGetAsset(ctx context.Context, address string, commitment rpc.CommitmentType) (*Asset, error) {
for {
account, err := c.rpcClient.GetAccountInfoWithOpts(ctx, solana.MPK(address), &rpc.GetAccountInfoOpts{
Encoding: "jsonParsed",
Commitment: rpc.CommitmentProcessed,
Commitment: commitment,
})
if mtg.CheckRetryableError(err) {
time.Sleep(time.Millisecond * 300)
Expand Down Expand Up @@ -144,6 +152,60 @@ func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error
}
}

// RPCGetMintCreationSignature recovers the actual creation transaction rather
// than using a freshly constructed transaction's signature after a restart.
func (c *Client) RPCGetMintCreationSignature(ctx context.Context, address string) (string, error) {
mint := solana.MustPublicKeyFromBase58(address)
opts := &rpc.GetSignaturesForAddressOpts{Commitment: rpc.CommitmentConfirmed}
for {
signatures, err := c.rpcClient.GetSignaturesForAddressWithOpts(ctx, mint, opts)
if err != nil {
return "", err
}
if len(signatures) == 0 {
return "", fmt.Errorf("mint creation transaction not found: %s", address)
}
for _, signature := range signatures {
if signature.Err != nil {
continue
}
result, err := c.RPCGetTransaction(ctx, signature.Signature.String())
if err != nil {
return "", err
}
if result == nil || result.Meta.Err != nil {
continue
}
tx, err := result.Transaction.GetTransaction()
if err != nil {
return "", err
}
if !tx.IsSigner(mint) {
continue
}
for _, ix := range tx.Message.Instructions {
program, err := tx.Message.Program(ix.ProgramIDIndex)
if err != nil || program != system.ProgramID {
continue
}
accounts, err := ix.ResolveInstructionAccounts(&tx.Message)
if err != nil {
return "", err
}
create, ok := DecodeCreateAccount(accounts, ix.Data)
if ok && create.GetNewAccount().PublicKey == mint && *create.Owner == solana.TokenProgramID && *create.Space == MintSize {
return signature.Signature.String(), nil
}
}
}
before := signatures[len(signatures)-1].Signature
if before == opts.Before {
return "", fmt.Errorf("mint transaction history did not advance: %s", address)
}
opts.Before = before
}
}

func (c *Client) RPCGetBalance(ctx context.Context, account solana.PublicKey) (uint64, error) {
for {
result, err := c.rpcClient.GetBalance(ctx, account, rpc.CommitmentProcessed)
Expand Down Expand Up @@ -217,7 +279,7 @@ func (c *Client) RPCGetTransaction(ctx context.Context, signature string) (*rpc.
time.Sleep(time.Millisecond * 300)
continue
}
if err != nil || r.Meta == nil {
if err != nil || r == nil || r.Meta == nil {
return nil, fmt.Errorf("solana.GetTransaction(%s) => %v", signature, err)
}

Expand Down
13 changes: 6 additions & 7 deletions solana/computer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/MixinNetwork/bot-api-go-client/v3"
solanaApp "github.com/MixinNetwork/computer/apps/solana"
"github.com/MixinNetwork/computer/store"
"github.com/MixinNetwork/mixin/crypto"
"github.com/MixinNetwork/mixin/logger"
Expand Down Expand Up @@ -851,7 +852,7 @@ func testObserverDeployAsset(ctx context.Context, require *require.Assertions, n
},
})
require.Nil(err)
id, _, assets, err := node.CreateMintTransaction(ctx, common.SafeLitecoinChainId)
id, _, asset, err := node.CreateMintTransaction(ctx, common.SafeLitecoinChainId)
require.Nil(err)
as, err := node.store.ListUndeployedAssets(ctx)
require.Nil(err)
Expand All @@ -870,18 +871,16 @@ func testObserverDeployAsset(ctx context.Context, require *require.Assertions, n
require.Equal("90", as[0].PriceUSD)

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()...)
}
extra = append(extra, byte(1))
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 {
testStep(ctx, require, node, out)
}

err = node.store.MarkExternalAssetDeployed(ctx, assets, "MBsH9LRbrx4u3kMkFkGuDyxjj3Pio55Puwv66dtR2M3CDfaR7Ef7VEKHDGM7GhB3fE1Jzc7k3zEZ6hvJ399UBNi")
err = node.store.MarkExternalAssetDeployed(ctx, []*solanaApp.DeployedAsset{asset}, "MBsH9LRbrx4u3kMkFkGuDyxjj3Pio55Puwv66dtR2M3CDfaR7Ef7VEKHDGM7GhB3fE1Jzc7k3zEZ6hvJ399UBNi")
require.Nil(err)
as, err = node.store.ListUndeployedAssets(ctx)
require.Nil(err)
Expand Down
57 changes: 48 additions & 9 deletions solana/icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"fmt"
"image"
"io"
"mime"
"net/http"
"strings"
"time"

"github.com/MixinNetwork/bot-api-go-client/v3"
solanaApp "github.com/MixinNetwork/computer/apps/solana"
Expand All @@ -18,22 +21,58 @@
"github.com/fogleman/gg"
)

const size = 512
const (
size = 512
maxIconBytes = 5 << 20
maxIconDimension = 4096
maxIconPixels = 4 << 20
imageHost = "images.mixin.one"
)

func readImageFromUrl(url string) (*image.NRGBA, error) {
if url[len(url)-5:] == "=s128" {
url = url[:len(url)-5] + "=s512"
func readImageFromUrl(ctx context.Context, url string) (*image.NRGBA, error) {
if strings.HasSuffix(url, "=s128") {
url = strings.TrimSuffix(url, "=s128") + "=s512"
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
res, err := http.Get(url)
if err != nil || res.StatusCode != 200 {
return nil, fmt.Errorf("http.Get(%s) => %v, %v", url, res, err)
if req.URL.Scheme != "https" || req.URL.Host != imageHost || req.URL.User != nil {
return nil, fmt.Errorf("icon URL must originate from https://images.mixin.one")
}
// RoundTrip performs one request without following redirects to another origin.
res, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
return nil, err
}
defer res.Body.Close()

Check failure on line 50 in solana/icon.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `res.Body.Close` is not checked (errcheck)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("icon HTTP status: %d", res.StatusCode)
}
contentType, _, err := mime.ParseMediaType(res.Header.Get("Content-Type"))
if err != nil || !strings.HasPrefix(contentType, "image/") {
return nil, fmt.Errorf("invalid icon content type: %q", res.Header.Get("Content-Type"))
}
if res.ContentLength > maxIconBytes {
return nil, fmt.Errorf("icon exceeds %d bytes", maxIconBytes)
}

data, err := io.ReadAll(res.Body)
data, err := io.ReadAll(io.LimitReader(res.Body, maxIconBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxIconBytes {
return nil, fmt.Errorf("icon exceeds %d bytes", maxIconBytes)
}
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, err
}
if config.Width <= 0 || config.Height <= 0 || config.Width > maxIconDimension || config.Height > maxIconDimension || config.Width > maxIconPixels/config.Height {
return nil, fmt.Errorf("invalid icon dimensions: %dx%d", config.Width, config.Height)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
Expand Down Expand Up @@ -64,7 +103,7 @@
}

func (node *Node) processAssetIcon(ctx context.Context, asset *bot.AssetNetwork) (string, error) {
icon, err := readImageFromUrl(asset.IconURL)
icon, err := readImageFromUrl(ctx, asset.IconURL)
if err != nil {
return "", err
}
Expand Down
65 changes: 43 additions & 22 deletions solana/observer.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,8 @@ func (node *Node) deployOrConfirmAssetsLoop(ctx context.Context) {
for {
err := node.deployOrConfirmAssets(ctx)
if err != nil {
panic(err)
logger.Printf("node.deployOrConfirmAssets() => %v", err)
}

time.Sleep(loopInterval)
}
}
Expand Down Expand Up @@ -354,6 +353,7 @@ func (node *Node) deployOrConfirmAssets(ctx context.Context) error {
}

for _, a := range es {
time.Sleep(time.Second)
old, err := node.store.ReadDeployedAsset(ctx, a.AssetId)
if err != nil {
return err
Expand All @@ -362,14 +362,41 @@ func (node *Node) deployOrConfirmAssets(ctx context.Context) error {
continue
}

id, tx, assets, err := node.CreateMintTransaction(ctx, a.AssetId)
if err != nil || tx == nil {
err = node.deployExternalAsset(ctx, a)
if err != nil {
logger.Printf("deploy external asset %s => %v", a.AssetId, err)
continue
}
}
return nil
}

func (node *Node) deployExternalAsset(ctx context.Context, a *store.ExternalAsset) error {
id, tx, asset, err := node.CreateMintTransaction(ctx, a.AssetId)
if err != nil {
return err
}
if tx == nil {
return fmt.Errorf("missing mint transaction for %s", a.AssetId)
}
mint, err := node.solana.RPCGetConfirmedAsset(ctx, asset.Address)
if err != nil {
return err
}
var hash string
if mint != nil {
if err := validateExternalAssetMint(asset.Address, node.getMTGAddress(ctx).String(), mint); err != nil {
return err
}
hash, err = node.solana.RPCGetMintCreationSignature(ctx, asset.Address)
if err != nil {
return err
}
} else {
payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey)
_, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer))
if err != nil {
panic(err)
return err
}
rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, nil)
if err != nil {
Expand All @@ -379,24 +406,18 @@ func (node *Node) deployOrConfirmAssets(ctx context.Context) error {
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())
hash = tx.Signatures[0].String()
}
return nil
extra := []byte{1}
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, []*solanaApp.DeployedAsset{asset}, hash)
}

func (node *Node) createNonceAccounts(ctx context.Context) error {
Expand Down
2 changes: 1 addition & 1 deletion solana/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func (node *Node) parseSignerResponse(out *mtg.Action) (*store.Request, error) {
func (node *Node) parseUserRequest(out *mtg.Action) (*store.Request, error) {
a, m := mtg.DecodeMixinExtraHEX(out.Extra)
if a != node.conf.AppId {
panic(out.Extra)
return nil, fmt.Errorf("invalid user app id: %s", a)
}
if len(m) == 0 {
return nil, fmt.Errorf("node.parseUserRequest(%v)", out)
Expand Down
28 changes: 13 additions & 15 deletions solana/solana.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,9 @@ func (node *Node) InitializeAccount(ctx context.Context, account string) error {
return err
}

func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (string, *solana.Transaction, []*solanaApp.DeployedAsset, error) {
func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (string, *solana.Transaction, *solanaApp.DeployedAsset, error) {
tid := fmt.Sprintf("GROUP:%s:OBSERVER:%s:MEMBERS:%v:%d", node.group.GenesisId(), node.id, node.GetMembers(), node.conf.MTG.Genesis.Threshold)
var assets []*solanaApp.DeployedAsset
var da *solanaApp.DeployedAsset
if common.CheckTestEnvironment(ctx) {
tid = common.UniqueId(tid, common.SafeLitecoinChainId)
ltc, err := common.SafeReadAssetUntilSufficient(ctx, common.SafeLitecoinChainId)
Expand All @@ -369,14 +369,12 @@ func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (stri
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,
},
da = &solanaApp.DeployedAsset{
AssetId: ltc.AssetID,
Address: "EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8",
Uri: "https://uploads.mixin.one/mixin/attachments/1739005826-2dc1afa3f3327f4d29cbb02e3b41cf57d4842f3c444e8e829871699ac43d21b2",
PrivateKey: &key,
Asset: ltc,
}
} else {
na, err := common.SafeReadAssetUntilSufficient(ctx, asset)
Expand All @@ -392,25 +390,25 @@ func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (stri
id = common.UniqueId(id, node.SafeUser().SpendPrivateKey)
seed := crypto.Sha256Hash([]byte(id))
key := solanaApp.PrivateKeyFromSeed(seed[:])
assets = append(assets, &solanaApp.DeployedAsset{
da = &solanaApp.DeployedAsset{
AssetId: asset,
Address: key.PublicKey().String(),
Uri: uri,
Asset: na,
PrivateKey: &key,
})
}
}

// Account funding must use current rent rather than the node's cached quote.
rent, err := node.solana.RPCGetMinimumBalanceForRentExemption(ctx, solanaApp.MintSize)
if err != nil {
panic(err)
return "", nil, nil, err
}
tx, err := node.solana.CreateMints(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), assets, rent)
tx, err := node.solana.CreateMints(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), []*solanaApp.DeployedAsset{da}, rent)
if err != nil {
return "", nil, nil, err
}
return tid, tx, assets, nil
return tid, tx, da, nil
}

func (node *Node) CreateNonceAccount(ctx context.Context, index int) (string, string, error) {
Expand Down
Loading