206 lines
4.8 KiB
Go
206 lines
4.8 KiB
Go
|
|
package clients
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/ed25519"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/tls"
|
||
|
|
"crypto/x509"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"math/big"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gagliardetto/solana-go"
|
||
|
|
|
||
|
|
"github.com/mr-tron/base58"
|
||
|
|
"github.com/quic-go/quic-go"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
alpnTPUProtocolID = "solana-tpu"
|
||
|
|
defaultServerName = "soyas-landing"
|
||
|
|
defaultKeepAlive = 25 * time.Second
|
||
|
|
defaultIdleTimeout = 5 * time.Minute
|
||
|
|
)
|
||
|
|
|
||
|
|
type SoyasClient struct {
|
||
|
|
endpointAddr string
|
||
|
|
tlsConfig *tls.Config
|
||
|
|
quicConfig *quic.Config
|
||
|
|
|
||
|
|
connMu sync.RWMutex
|
||
|
|
conn *quic.Conn
|
||
|
|
reconnectMu sync.Mutex
|
||
|
|
}
|
||
|
|
|
||
|
|
// Connect creates a client using the whitelisted Solana keypair (base58-encoded secret key) as the mutual-TLS client identity.
|
||
|
|
func NewSoyasClient(ctx context.Context, url string) *SoyasClient {
|
||
|
|
cert, err := x509CertificateFromSolanaBase58Key("2ketcrBU1kBvr68sPVYdBdn5ztgg3VBKZP1xa1o5B8w47wemBXH73ZALdmj3ukcGzkxh6DhzLq3myu45XUwW1eNC")
|
||
|
|
if err != nil {
|
||
|
|
panic(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
tlsConfig := &tls.Config{
|
||
|
|
Certificates: []tls.Certificate{cert},
|
||
|
|
ServerName: defaultServerName,
|
||
|
|
InsecureSkipVerify: true,
|
||
|
|
NextProtos: []string{alpnTPUProtocolID},
|
||
|
|
MinVersion: tls.VersionTLS13,
|
||
|
|
}
|
||
|
|
|
||
|
|
quicConfig := &quic.Config{
|
||
|
|
KeepAlivePeriod: defaultKeepAlive,
|
||
|
|
MaxIdleTimeout: defaultIdleTimeout,
|
||
|
|
}
|
||
|
|
|
||
|
|
client := &SoyasClient{
|
||
|
|
endpointAddr: url,
|
||
|
|
tlsConfig: tlsConfig,
|
||
|
|
quicConfig: quicConfig,
|
||
|
|
}
|
||
|
|
|
||
|
|
if err = client.reconnect(ctx); err != nil {
|
||
|
|
panic(err)
|
||
|
|
}
|
||
|
|
return client
|
||
|
|
}
|
||
|
|
|
||
|
|
// Close closes the underlying QUIC connection (if any). Safe to call multiple times.
|
||
|
|
func (c *SoyasClient) Close() error {
|
||
|
|
c.reconnectMu.Lock()
|
||
|
|
defer c.reconnectMu.Unlock()
|
||
|
|
|
||
|
|
c.connMu.Lock()
|
||
|
|
conn := c.conn
|
||
|
|
c.conn = nil
|
||
|
|
c.connMu.Unlock()
|
||
|
|
|
||
|
|
if conn == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return conn.CloseWithError(0, "")
|
||
|
|
}
|
||
|
|
|
||
|
|
// SendTransaction sends a signed Solana transaction payload to Soyas.
|
||
|
|
// The payload should be the raw wire bytes (for example, from solana-go's tx.MarshalBinary()).
|
||
|
|
// If sending fails, it reconnects once and retries.
|
||
|
|
func (c *SoyasClient) SendTransaction(ctx context.Context, tx *solana.Transaction) error {
|
||
|
|
if c.endpointAddr == "" {
|
||
|
|
return fmt.Errorf("send tx url is empty")
|
||
|
|
}
|
||
|
|
|
||
|
|
raw, err := tx.MarshalBinary()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
conn := c.getConn()
|
||
|
|
if conn != nil {
|
||
|
|
if err := trySendBytes(ctx, conn, raw); err == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := c.reconnect(ctx); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
conn = c.getConn()
|
||
|
|
if conn == nil {
|
||
|
|
return errors.New("missing QUIC connection")
|
||
|
|
}
|
||
|
|
return trySendBytes(ctx, conn, raw)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *SoyasClient) SendBundle(ctx context.Context, txs []*solana.Transaction) error {
|
||
|
|
return fmt.Errorf("soyas client not support send bundle")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *SoyasClient) getConn() *quic.Conn {
|
||
|
|
c.connMu.RLock()
|
||
|
|
defer c.connMu.RUnlock()
|
||
|
|
return c.conn
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *SoyasClient) reconnect(ctx context.Context) error {
|
||
|
|
c.reconnectMu.Lock()
|
||
|
|
defer c.reconnectMu.Unlock()
|
||
|
|
|
||
|
|
if existing := c.getConn(); existing != nil && existing.Context().Err() == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
conn, err := quic.DialAddr(ctx, c.endpointAddr, c.tlsConfig, c.quicConfig)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
c.connMu.Lock()
|
||
|
|
old := c.conn
|
||
|
|
c.conn = conn
|
||
|
|
c.connMu.Unlock()
|
||
|
|
|
||
|
|
if old != nil {
|
||
|
|
_ = old.CloseWithError(0, "")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func trySendBytes(ctx context.Context, conn *quic.Conn, payload []byte) error {
|
||
|
|
stream, err := conn.OpenUniStreamSync(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if _, err := stream.Write(payload); err != nil {
|
||
|
|
_ = stream.Close()
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return stream.Close()
|
||
|
|
}
|
||
|
|
|
||
|
|
// x509CertificateFromSolanaBase58Key creates a short-lived self-signed X.509
|
||
|
|
// certificate whose public key is derived from the provided Solana Ed25519 key.
|
||
|
|
// The Soyas ingress extracts this public key to identify/allowlist the client.
|
||
|
|
func x509CertificateFromSolanaBase58Key(apiKeyBase58 string) (tls.Certificate, error) {
|
||
|
|
raw, err := base58.Decode(apiKeyBase58)
|
||
|
|
if err != nil {
|
||
|
|
return tls.Certificate{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
var seed []byte
|
||
|
|
switch len(raw) {
|
||
|
|
case ed25519.SeedSize:
|
||
|
|
seed = raw
|
||
|
|
case ed25519.PrivateKeySize:
|
||
|
|
seed = raw[:ed25519.SeedSize]
|
||
|
|
default:
|
||
|
|
return tls.Certificate{}, errors.New("api key must decode to 32 (seed) or 64 (secret) bytes")
|
||
|
|
}
|
||
|
|
|
||
|
|
priv := ed25519.NewKeyFromSeed(seed)
|
||
|
|
pub := priv.Public().(ed25519.PublicKey)
|
||
|
|
|
||
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||
|
|
if err != nil {
|
||
|
|
return tls.Certificate{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
template := &x509.Certificate{
|
||
|
|
SerialNumber: serial,
|
||
|
|
NotBefore: time.Now().Add(-5 * time.Minute),
|
||
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||
|
|
}
|
||
|
|
|
||
|
|
der, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv)
|
||
|
|
if err != nil {
|
||
|
|
return tls.Certificate{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return tls.Certificate{
|
||
|
|
Certificate: [][]byte{der},
|
||
|
|
PrivateKey: priv,
|
||
|
|
}, nil
|
||
|
|
}
|