internal/app/wallet/service.go
Rama main · réplica de lectura del repositorio original.
package wallet
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/url"
"strconv"
"time"
"telesrv/internal/domain"
)
type Store interface {
PutWalletChallenge(context.Context, domain.WalletChallenge) error
WalletChallenge(context.Context, int64, [8]byte) (domain.WalletChallenge, bool, error)
// CommitWalletLink atomically consumes the exact challenge, compares the
// existing wallet, and commits a link. Only an identical request may replay.
CommitWalletLink(context.Context, domain.WalletChallenge, domain.WalletLink, [32]byte, bool, time.Time) error
// CommitWalletCreated atomically binds a telesrv-generated wallet and its
// sealed private key. When the challenge was already consumed it returns
// the stored link with reused=true so a retry cannot mint a second wallet.
CommitWalletCreated(context.Context, domain.WalletChallenge, domain.WalletLink, *StoredWalletKey, bool, time.Time) (domain.WalletLink, bool, error)
WalletLinks(context.Context, []int64) ([]domain.WalletLink, error)
}
type Provider interface {
Perform(context.Context, string, *string, *string) (string, error)
}
// BalanceSource resolves one account's TON balance in nanoton. telesrv's own
// TON ledger implements it today (the same one that backs star-gift proceeds);
// a real chain client replaces it later without touching proof, storage or RPC
// code.
type BalanceSource interface {
AccountBalance(ctx context.Context, userID int64) (int64, error)
}
// TransactionSource supplies the account's ledger history. It is injected the
// same way BalanceSource is, because the ledger lives in telesrv's own database
// rather than in the wallet state directory: the file store holds keys and
// bindings, not money movement.
type TransactionSource interface {
AccountTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
}
// TONLedgerTransactionSource reads history straight out of telesrv's internal
// nanoton ledger.
func TONLedgerTransactionSource(transactions func(context.Context, int64, domain.StarsTransactionQuery) (domain.TonTransactionPage, error)) TransactionSource {
return transactionSourceFunc(transactions)
}
type transactionSourceFunc func(context.Context, int64, domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
func (f transactionSourceFunc) AccountTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if f == nil {
return domain.TonTransactionPage{}, ErrProviderUnavailable
}
return f(ctx, userID, query)
}
// WithTransactionSource makes the ledger's history readable through the wallet.
func WithTransactionSource(source TransactionSource) ServiceOption {
return func(s *Service) {
s.transactions = source
}
}
// balanceSourceFunc adapts a plain balance lookup to BalanceSource.
type balanceSourceFunc func(context.Context, int64) (int64, error)
func (f balanceSourceFunc) AccountBalance(ctx context.Context, userID int64) (int64, error) {
if f == nil {
return 0, ErrProviderUnavailable
}
return f(ctx, userID)
}
// TONLedgerBalanceSource binds wallet balances to telesrv's internal TON
// ledger (nanoton, the same one that credits star-gift proceeds). Passing a nil
// lookup yields a source that always reports the ledger as unavailable.
func TONLedgerBalanceSource(balance func(context.Context, int64) (int64, error)) BalanceSource {
return balanceSourceFunc(balance)
}
// ServiceOption configures optional capabilities.
type ServiceOption func(*Service)
// WithBalanceSource makes wallet balances resolve from telesrv's internal TON
// ledger instead of a provider. Without it a wallet has no balance source at
// all, and every binding attempt fails closed rather than storing a fake zero.
func WithBalanceSource(source BalanceSource) ServiceOption {
return func(s *Service) {
s.balances = source
}
}
// WithKeyCipher enables server-held wallets: telesrv generates the key pair,
// seals the private key under the master key and keeps the ciphertext in its
// own state directory. Without it inputWalletNew is refused, because telesrv
// must never hold a key it cannot seal.
func WithKeyCipher(keys *KeyCipher) ServiceOption {
return func(s *Service) {
s.keys = keys
}
}
type Service struct {
store Store
provider Provider
balances BalanceSource
transactions TransactionSource
transfers TransferStore
keys *KeyCipher
domain string
now func() time.Time
}
// NewService wires the wallet identity store. provider may be nil: telesrv does
// not contact any TON node by default, and every provider-backed method then
// answers ErrProviderUnavailable instead of pretending a chain exists.
func NewService(store Store, provider Provider, proofDomain string, opts ...ServiceOption) *Service {
if store == nil || proofDomain == "" {
panic("wallet: missing service dependency")
}
s := &Service{store: store, provider: provider, domain: proofDomain, now: time.Now}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
// accountBalance resolves the account balance from the local TON ledger when
// one is wired, otherwise from the configured provider. It never invents a
// zero: an absent source is an error so a failed lookup cannot persist a
// misleading balance alongside a verified ownership proof.
func (s *Service) accountBalance(ctx context.Context, userID int64, address string) (int64, error) {
if s.balances != nil {
return s.balances.AccountBalance(ctx, userID)
}
return s.Balance(ctx, address)
}
func (s *Service) GetProofChallenge(ctx context.Context, userID int64, key [8]byte) (domain.WalletChallenge, error) {
if userID <= 0 || key == ([8]byte{}) {
return domain.WalletChallenge{}, domain.ErrWalletProofInvalid
}
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return domain.WalletChallenge{}, err
}
now := s.now().UTC().Truncate(time.Second)
c := domain.WalletChallenge{UserID: userID, AuthKeyID: key, Payload: base64.RawURLEncoding.EncodeToString(random), Domain: s.domain, IssuedAt: now, ExpiresAt: now.Add(5 * time.Minute)}
if err := s.store.PutWalletChallenge(ctx, c); err != nil {
return domain.WalletChallenge{}, err
}
return c, nil
}
func (s *Service) Links(ctx context.Context, ids []int64) ([]domain.WalletLink, error) {
if len(ids) > 100 {
return nil, ErrProviderRequest
}
return s.store.WalletLinks(ctx, ids)
}
// State reports the account's current wallet plus its balance from whichever
// source is configured. An unbound account is reported as not-ok rather than as
// an empty address, so the client renders "no wallet" instead of a broken one.
func (s *Service) State(ctx context.Context, userID int64) (domain.WalletLink, int64, bool, error) {
if userID <= 0 {
return domain.WalletLink{}, 0, false, domain.ErrWalletProofInvalid
}
links, err := s.store.WalletLinks(ctx, []int64{userID})
if err != nil {
return domain.WalletLink{}, 0, false, err
}
if len(links) == 0 {
return domain.WalletLink{}, 0, false, nil
}
balance, err := s.accountBalance(ctx, userID, links[0].Address)
if err != nil {
return domain.WalletLink{}, 0, false, err
}
return links[0], balance, true, nil
}
// Create mints a TON wallet for an account and keeps its private key sealed
// under the server master key. The client never receives key material: only
// the derived mainnet address and public key leave the process.
//
// Creation consumes the account's current ownership challenge, which is what
// makes a client retry return the same wallet instead of minting a second one.
// Replacing an existing wallet still requires the caller to have passed the
// account password check (allowReplacement).
func (s *Service) Create(ctx context.Context, userID int64, key [8]byte, allowReplacement bool) (domain.WalletLink, int64, error) {
if s == nil || s.keys == nil {
return domain.WalletLink{}, 0, ErrWalletCreationDisabled
}
if userID <= 0 || key == ([8]byte{}) {
return domain.WalletLink{}, 0, domain.ErrWalletProofInvalid
}
c, found, err := s.store.WalletChallenge(ctx, userID, key)
if err != nil {
return domain.WalletLink{}, 0, err
}
now := s.now().UTC()
if !found || !now.Before(c.ExpiresAt) {
return domain.WalletLink{}, 0, domain.ErrWalletProofInvalid
}
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return domain.WalletLink{}, 0, err
}
addr, err := InitialAddress(public, false)
if err != nil {
return domain.WalletLink{}, 0, err
}
link := domain.WalletLink{UserID: userID, Address: addr.String(), PublicKey: bytes.Clone(public)}
// The local ledger is the balance source; without one the creation must
// fail rather than persist a wallet whose reported balance is fictional.
balance, err := s.accountBalance(ctx, userID, link.Address)
if err != nil {
return domain.WalletLink{}, 0, err
}
ciphertext, nonce, err := s.keys.Seal(private)
// Drop the plaintext key as early as possible; it is never stored or logged.
clear(private)
if err != nil {
return domain.WalletLink{}, 0, err
}
stored := &StoredWalletKey{
Ciphertext: base64.RawStdEncoding.EncodeToString(ciphertext),
Nonce: base64.RawStdEncoding.EncodeToString(nonce),
CreatedAt: now,
Origin: "telesrv",
}
// A retry returns the wallet that won the first commit; the candidate key
// generated above is discarded unwritten.
committed, _, err := s.store.CommitWalletCreated(ctx, c, link, stored, allowReplacement, now)
if err != nil {
return domain.WalletLink{}, 0, err
}
return committed, balance, nil
}
func (s *Service) ReplaceImported(ctx context.Context, userID int64, key [8]byte, publicKey []byte, timestamp int, signature []byte, allowReplacement bool) (domain.WalletLink, int64, error) {
if userID <= 0 || len(publicKey) != 32 || len(signature) != 64 || timestamp <= 0 {
return domain.WalletLink{}, 0, domain.ErrWalletProofInvalid
}
c, found, err := s.store.WalletChallenge(ctx, userID, key)
if err != nil {
return domain.WalletLink{}, 0, err
}
now := s.now().UTC()
if !found || !now.Before(c.ExpiresAt) || int64(timestamp) < c.IssuedAt.Unix()-30 || int64(timestamp) > now.Unix()+30 {
return domain.WalletLink{}, 0, domain.ErrWalletProofInvalid
}
addr, err := InitialAddress(publicKey, false)
if err != nil {
return domain.WalletLink{}, 0, domain.ErrWalletProofInvalid
}
if err = VerifyProof(0, addr.Data(), publicKey, c.Domain, uint64(timestamp), c.Payload, signature); err != nil {
return domain.WalletLink{}, 0, domain.ErrWalletProofInvalid
}
// A failed balance lookup must not manufacture a zero balance or mutate
// the persisted association. The client retains its local secret on error.
balance, err := s.accountBalance(ctx, userID, addr.String())
if err != nil {
return domain.WalletLink{}, 0, err
}
link := domain.WalletLink{UserID: userID, Address: addr.String(), PublicKey: bytes.Clone(publicKey)}
request := append(bytes.Clone(publicKey), binary.LittleEndian.AppendUint32(nil, uint32(timestamp))...)
request = append(request, signature...)
if err = s.store.CommitWalletLink(ctx, c, link, sha256.Sum256(request), allowReplacement, now); err != nil {
return domain.WalletLink{}, 0, err
}
return link, balance, nil
}
func (s *Service) Balance(ctx context.Context, address string) (int64, error) {
if s == nil || s.provider == nil {
return 0, ErrProviderUnavailable
}
query := url.Values{"address": {address}}.Encode()
raw, err := s.provider.Perform(ctx, "/api/v2/getAddressBalance", &query, nil)
if err != nil {
return 0, err
}
var response struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
}
if json.Unmarshal([]byte(raw), &response) != nil || !response.OK {
return 0, ErrProviderResponse
}
var value string
if json.Unmarshal(response.Result, &value) != nil {
value = string(response.Result)
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n < 0 {
return 0, ErrProviderResponse
}
return n, nil
}
func (s *Service) Perform(ctx context.Context, endpoint string, query, payload *string) (string, error) {
if s == nil || s.provider == nil {
return "", ErrProviderUnavailable
}
return s.provider.Perform(ctx, endpoint, query, payload)
}
// Transactions returns history from the configured ledger and fails closed without it.
func (s *Service) Transactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
if s == nil || userID <= 0 {
return domain.TonTransactionPage{}, domain.ErrWalletProofInvalid
}
if s.transactions == nil {
return domain.TonTransactionPage{}, ErrProviderUnavailable
}
page, err := s.transactions.AccountTransactions(ctx, userID, query)
if err != nil {
return domain.TonTransactionPage{}, err
}
if page.Transactions == nil {
page.Transactions = []domain.TonTransaction{}
}
return page, nil
}