Ctrl K
ComunidadGuías…

internal/customfragment/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package customfragment implements gramsrv's self-hosted collectible-gift
// bridge. A withdrawal URL is a short-lived bearer capability created only
// after payments.getStarGiftWithdrawalUrl has checked the user's 2FA password.
package customfragment

import (
	"context"
	"crypto/ed25519"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"image"
	"math"
	"math/big"
	"net/url"
	"os"
	"path"
	"strings"
	"sync"
	"time"

	"github.com/xssnick/tonutils-go/address"
	"github.com/xssnick/tonutils-go/tvm/cell"
	"go.uber.org/zap"
	"golang.org/x/sync/singleflight"

	"telesrv/internal/domain"
	"telesrv/internal/lottierender"
)

const (
	mintOpcode            = uint64(0x4637289b)
	mainnetChain          = "-239"
	defaultSubwalletID    = uint32(0x4752414d) // "GRAM"
	defaultMintAmount     = int64(80_000_000)
	defaultAuthorization  = 5 * time.Minute
	maxWithdrawalTokenLen = 256
)

var (
	ErrInvalidRequest  = errors.New("invalid CustomFragment request")
	ErrUnavailable     = errors.New("CustomFragment unavailable")
	ErrNotFinalized    = errors.New("gift NFT is not finalized on TON mainnet")
	ErrAlreadyExported = errors.New("gift is already exported")
)

type Config struct {
	PublicBaseURL       string
	AppName             string
	SigningKeyFile      string
	SigningKey          ed25519.PrivateKey // tests and in-process tooling only
	GiftCollection      string
	CollectionName      string
	MintAmountNanoton   int64
	SubwalletID         uint32
	AuthorizationTTL    time.Duration
	LiteserverConfigURL string
	ModelRenderer       ModelRenderFunc // tests or an alternate production renderer
	// Limiter bounds gift API requests per client IP; nil disables limiting.
	Limiter Limiter
	// APIRateLimit is the per-IP budget for intent/confirm requests; <=0 disables.
	APIRateLimit int
	// APIRateWindow is the sliding window for APIRateLimit.
	APIRateWindow time.Duration
}

type ModelRenderFunc func(data []byte, width, height int, position float64) (*image.NRGBA, error)

type GiftLedger interface {
	ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
	CompleteWithdrawalOnChain(ctx context.Context, providerRequestID, ownerAddress, giftAddress string, date int) (domain.StarGiftWithdrawal, error)
	UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
}

// Verifier is intentionally narrow so payload generation is unit-testable
// without network access. Production uses a proof-checking lite-server client.
type Verifier interface {
	VerifyMint(ctx context.Context, collectionRaw string, itemIndex *big.Int, ownerRaw string) (itemRaw string, err error)
	Close()
}

// Limiter matches store.RateLimiter so this package keeps no store dependency.
type Limiter interface {
	Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
}

type Service struct {
	publicBaseURL  string
	appName        string
	privateKey     ed25519.PrivateKey
	publicKey      ed25519.PublicKey
	collection     *address.Address
	collectionName string
	mintAmount     int64
	subwalletID    uint32
	authTTL        time.Duration
	ledger         GiftLedger
	verifier       Verifier
	renderModel    ModelRenderFunc
	imageMu        sync.RWMutex
	imageCache     map[string][]byte
	animationCache map[string][]byte
	confirmSF      singleflight.Group
	limiter        Limiter
	apiRateLimit   int
	apiRateWindow  time.Duration
	logger         *zap.Logger
}

type MintIntent struct {
	Network           string `json:"network"`
	CollectionAddress string `json:"collection_address"`
	Amount            string `json:"amount"`
	Payload           string `json:"payload"`
	ValidUntil        int64  `json:"valid_until"`
	ItemIndex         string `json:"item_index"`
	WalletAddress     string `json:"wallet_address"`
}

type Confirmation struct {
	Status       string `json:"status"`
	OwnerAddress string `json:"owner_address"`
	GiftAddress  string `json:"gift_address"`
	Collection   string `json:"collection_address"`
}

func New(cfg Config, ledger GiftLedger, verifier Verifier, logger *zap.Logger) (*Service, error) {
	if ledger == nil {
		return nil, fmt.Errorf("CustomFragment gift ledger is nil")
	}
	base, err := url.Parse(strings.TrimSpace(cfg.PublicBaseURL))
	if err != nil || base.Scheme == "" || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") {
		return nil, fmt.Errorf("CustomFragment public base URL is invalid")
	}
	base.Path = strings.TrimRight(base.Path, "/")
	base.RawQuery, base.Fragment = "", ""

	collection, err := parseMainnetAddress(cfg.GiftCollection)
	if err != nil {
		return nil, fmt.Errorf("CustomFragment gift collection: %w", err)
	}
	privateKey := append(ed25519.PrivateKey(nil), cfg.SigningKey...)
	if len(privateKey) == 0 {
		privateKey, err = loadSigningKey(cfg.SigningKeyFile)
		if err != nil {
			return nil, fmt.Errorf("CustomFragment signing key: %w", err)
		}
	}
	if len(privateKey) != ed25519.PrivateKeySize {
		return nil, fmt.Errorf("CustomFragment signing key must be an Ed25519 private key")
	}
	mintAmount := cfg.MintAmountNanoton
	if mintAmount == 0 {
		mintAmount = defaultMintAmount
	}
	if mintAmount < 40_000_000 || mintAmount > 2_000_000_000 {
		return nil, fmt.Errorf("CustomFragment mint amount must be 0.04..2 TON")
	}
	authTTL := cfg.AuthorizationTTL
	if authTTL == 0 {
		authTTL = defaultAuthorization
	}
	if authTTL < time.Minute || authTTL > 15*time.Minute {
		return nil, fmt.Errorf("CustomFragment authorization TTL must be 1m..15m")
	}
	subwalletID := cfg.SubwalletID
	if subwalletID == 0 {
		subwalletID = defaultSubwalletID
	}
	if verifier == nil {
		verifier = NewMainnetVerifier(cfg.LiteserverConfigURL)
	}
	if logger == nil {
		logger = zap.NewNop()
	}
	renderModel := cfg.ModelRenderer
	if renderModel == nil {
		renderModel = lottierender.Render
	}
	appName := strings.TrimSpace(cfg.AppName)
	if appName == "" {
		appName = "Gramsrv"
	}
	collectionName := strings.TrimSpace(cfg.CollectionName)
	if collectionName == "" {
		collectionName = "InvGram Gifts"
	}
	if len(collectionName) > 80 {
		return nil, fmt.Errorf("CustomFragment collection name is too long")
	}
	publicKey := privateKey.Public().(ed25519.PublicKey)
	return &Service{
		publicBaseURL: strings.TrimRight(base.String(), "/"), appName: appName,
		privateKey: privateKey, publicKey: publicKey, collection: collection, collectionName: collectionName,
		mintAmount: mintAmount, subwalletID: subwalletID, authTTL: authTTL,
		ledger: ledger, verifier: verifier, logger: logger,
		renderModel: renderModel,
		limiter:     cfg.Limiter, apiRateLimit: cfg.APIRateLimit, apiRateWindow: cfg.APIRateWindow,
		imageCache: make(map[string][]byte), animationCache: make(map[string][]byte),
	}, nil
}

func (s *Service) Close() {
	if s != nil && s.verifier != nil {
		s.verifier.Close()
	}
}

func (s *Service) PublicKeyHex() string {
	if s == nil {
		return ""
	}
	return hex.EncodeToString(s.publicKey)
}

func (s *Service) Intent(ctx context.Context, requestID, walletAddress string, now time.Time) (MintIntent, error) {
	if s == nil || !validRequestID(requestID) {
		return MintIntent{}, ErrInvalidRequest
	}
	withdrawal, found, err := s.ledger.ResolveWithdrawal(ctx, requestID)
	if err != nil {
		return MintIntent{}, err
	}
	if !found {
		return MintIntent{}, ErrInvalidRequest
	}
	if withdrawal.Status == "completed" {
		return MintIntent{}, ErrAlreadyExported
	}
	nowUnix := now.Unix()
	if withdrawal.Status != "pending" || withdrawal.ExpiresAt <= int(nowUnix) || withdrawal.Gift.ID <= 0 {
		return MintIntent{}, ErrUnavailable
	}
	owner, err := parseMainnetAddress(walletAddress)
	if err != nil {
		return MintIntent{}, ErrInvalidRequest
	}
	validSince := nowUnix - 30
	validUntil := now.Add(s.authTTL).Unix()
	if max := int64(withdrawal.ExpiresAt); validUntil > max {
		validUntil = max
	}
	if validSince < 0 || validUntil <= nowUnix || validUntil > math.MaxUint32 {
		return MintIntent{}, ErrUnavailable
	}
	itemIndex := big.NewInt(withdrawal.Gift.ID)
	metadataURL := s.publicBaseURL + path.Join("/custom-fragment/metadata/gift/", url.PathEscape(withdrawal.Gift.Slug)+".json")
	content := cell.BeginCell().MustStoreUInt(1, 8).MustStoreStringSnake(metadataURL).EndCell()
	authorization := cell.BeginCell().
		MustStoreUInt(uint64(s.subwalletID), 32).
		MustStoreUInt(uint64(validSince), 32).
		MustStoreUInt(uint64(validUntil), 32).
		MustStoreBigUInt(itemIndex, 256).
		MustStoreAddr(owner).
		MustStoreRef(content).
		EndCell()
	signature := ed25519.Sign(s.privateKey, authorization.Hash())
	body := cell.BeginCell().
		MustStoreUInt(mintOpcode, 32).
		MustStoreSlice(signature, 512).
		MustStoreRef(authorization).
		EndCell()

	return MintIntent{
		Network: mainnetChain, CollectionAddress: s.collection.String(),
		Amount:  fmt.Sprintf("%d", s.mintAmount),
		Payload: base64.StdEncoding.EncodeToString(body.ToBOC()), ValidUntil: validUntil,
		ItemIndex: itemIndex.String(), WalletAddress: owner.StringRaw(),
	}, nil
}

func (s *Service) Confirm(ctx context.Context, requestID, walletAddress string, now time.Time) (Confirmation, error) {
	if s == nil || !validRequestID(requestID) {
		return Confirmation{}, ErrInvalidRequest
	}
	value, err, _ := s.confirmSF.Do(requestID, func() (any, error) {
		return s.confirmOnce(ctx, requestID, walletAddress, now)
	})
	if err != nil {
		return Confirmation{}, err
	}
	confirmation, ok := value.(Confirmation)
	if !ok {
		return Confirmation{}, ErrUnavailable
	}
	return confirmation, nil
}

// confirmOnce is singleflighted per bearer request ID. The database transaction
// remains the cross-process authority; this layer only prevents a local burst
// of concurrent userbot retries from issuing duplicate lite-server reads.
func (s *Service) confirmOnce(ctx context.Context, requestID, walletAddress string, now time.Time) (Confirmation, error) {
	withdrawal, found, err := s.ledger.ResolveWithdrawal(ctx, requestID)
	if err != nil {
		return Confirmation{}, err
	}
	if !found {
		return Confirmation{}, ErrInvalidRequest
	}
	if withdrawal.Status == "completed" && withdrawal.Gift.OwnerAddress != "" && withdrawal.Gift.GiftAddress != "" {
		return Confirmation{Status: withdrawal.Status, OwnerAddress: withdrawal.Gift.OwnerAddress,
			GiftAddress: withdrawal.Gift.GiftAddress, Collection: s.collection.StringRaw()}, nil
	}
	if withdrawal.Status != "pending" || withdrawal.ExpiresAt <= int(now.Unix()) || withdrawal.Gift.ID <= 0 {
		return Confirmation{}, ErrUnavailable
	}
	owner, err := parseMainnetAddress(walletAddress)
	if err != nil {
		return Confirmation{}, ErrInvalidRequest
	}
	itemAddress, err := s.verifier.VerifyMint(ctx, s.collection.StringRaw(), big.NewInt(withdrawal.Gift.ID), owner.StringRaw())
	if err != nil {
		s.logger.Debug("CustomFragment gift is not finalized", zap.Int64("gift_id", withdrawal.Gift.ID), zap.Error(err))
		return Confirmation{}, fmt.Errorf("%w: %v", ErrNotFinalized, err)
	}
	item, err := parseMainnetAddress(itemAddress)
	if err != nil {
		return Confirmation{}, fmt.Errorf("%w: verifier returned invalid address", ErrNotFinalized)
	}
	completed, err := s.ledger.CompleteWithdrawalOnChain(ctx, requestID, owner.StringRaw(), item.StringRaw(), int(now.Unix()))
	if err != nil {
		return Confirmation{}, err
	}
	return Confirmation{Status: completed.Status, OwnerAddress: completed.Gift.OwnerAddress,
		GiftAddress: completed.Gift.GiftAddress, Collection: s.collection.StringRaw()}, nil
}

func validRequestID(value string) bool {
	value = strings.TrimSpace(value)
	return value != "" && len(value) <= maxWithdrawalTokenLen && !strings.ContainsAny(value, "/\\\x00")
}

func parseMainnetAddress(raw string) (*address.Address, error) {
	raw = strings.TrimSpace(raw)
	var parsed *address.Address
	var err error
	if strings.Contains(raw, ":") {
		parsed, err = address.ParseRawAddr(raw)
	} else {
		parsed, err = address.ParseAddr(raw)
	}
	if err != nil || parsed == nil || parsed.IsAddrNone() || parsed.Workchain() != 0 || parsed.IsTestnetOnly() {
		return nil, errors.New("expected a basechain mainnet TON address")
	}
	return parsed, nil
}

func loadSigningKey(filename string) (ed25519.PrivateKey, error) {
	filename = strings.TrimSpace(filename)
	if filename == "" {
		return nil, errors.New("signing key file is required")
	}
	raw, err := os.ReadFile(filename)
	if err != nil {
		return nil, err
	}
	trimmed := strings.TrimSpace(string(raw))
	var decoded []byte
	if value, decodeErr := hex.DecodeString(strings.TrimPrefix(trimmed, "hex:")); decodeErr == nil {
		decoded = value
	} else if value, decodeErr := base64.StdEncoding.DecodeString(strings.TrimPrefix(trimmed, "base64:")); decodeErr == nil {
		decoded = value
	} else if value, decodeErr := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(trimmed, "base64:")); decodeErr == nil {
		decoded = value
	} else {
		decoded = raw
	}
	switch len(decoded) {
	case ed25519.SeedSize:
		return ed25519.NewKeyFromSeed(decoded), nil
	case ed25519.PrivateKeySize:
		return ed25519.PrivateKey(append([]byte(nil), decoded...)), nil
	default:
		return nil, fmt.Errorf("key file must contain a 32-byte seed or 64-byte private key (hex/base64/raw)")
	}
}