diff --git a/.env.example b/.env.example index 4ccfb1b..b58bd0a 100644 --- a/.env.example +++ b/.env.example @@ -23,13 +23,14 @@ PAYPAL_CLIENT_SECRET=tu_client_secret_aqui PAYPAL_MODE=sandbox # Configuración de Stellar / Soroban +# Los tres valores SOROBAN_*/STELLAR_ADMIN_SECRET_KEY son obligatorios en producción. STELLAR_NETWORK=testnet STELLAR_RPC_URL=https://soroban-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" -# ID de tus contratos desplegados +# ID de tus contratos desplegados (deben ser direcciones de contrato válidas, formato C...) SOROBAN_TOKEN_MINT_CONTRACT_ID= SOROBAN_TOKEN_SALE_CONTRACT_ID= -# Clave privada del admin +# Clave secreta del admin que firma las operaciones de tokens (semilla válida, formato S...) STELLAR_ADMIN_SECRET_KEY= # Configuración de pagos mock diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index da2e23f..7ab6154 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -53,10 +53,12 @@ export const DEV_DEFAULTS = { network: 'testnet', rpcUrl: 'https://soroban-testnet.stellar.org', networkPassphrase: 'Test SDF Network ; September 2015', + // Soroban contract ids must be valid C... addresses; required in production. contracts: { tokenMint: '', tokenSale: '', }, + // Admin secret must be a valid S... seed; used to sign all token operations. Required in production. adminSecretKey: '', }, corsOrigins: ['*'], diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 4f3a3c9..c610dcf 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -1,5 +1,9 @@ +import { Keypair, StrKey } from '@stellar/stellar-sdk'; import { getValidatedEnv, resetValidatedEnvCache, validateEnv } from './env.validation'; +const VALID_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); +const VALID_ADMIN_SECRET = Keypair.random().secret(); + describe('validateEnv', () => { afterEach(() => { resetValidatedEnvCache(); @@ -70,6 +74,58 @@ describe('validateEnv', () => { ).toThrow('JWT_SECRET is required in production'); }); + it('parses and validates well-formed Soroban contract ids and admin secret', () => { + const env = validateEnv({ + NODE_ENV: 'development', + SOROBAN_TOKEN_MINT_CONTRACT_ID: VALID_CONTRACT_ID, + SOROBAN_TOKEN_SALE_CONTRACT_ID: VALID_CONTRACT_ID, + STELLAR_ADMIN_SECRET_KEY: VALID_ADMIN_SECRET, + }); + + expect(env.stellar.contracts).toEqual({ + tokenMint: VALID_CONTRACT_ID, + tokenSale: VALID_CONTRACT_ID, + }); + expect(env.stellar.adminSecretKey).toBe(VALID_ADMIN_SECRET); + }); + + it('rejects malformed Soroban contract ids', () => { + expect(() => + validateEnv({ + NODE_ENV: 'development', + SOROBAN_TOKEN_MINT_CONTRACT_ID: 'not-a-contract', + }), + ).toThrow('SOROBAN_TOKEN_MINT_CONTRACT_ID must be a valid Soroban contract id (C...)'); + }); + + it('rejects malformed admin secret keys', () => { + expect(() => + validateEnv({ + NODE_ENV: 'development', + STELLAR_ADMIN_SECRET_KEY: 'not-a-secret', + }), + ).toThrow('STELLAR_ADMIN_SECRET_KEY must be a valid Stellar secret seed (S...)'); + }); + + it('requires Soroban contract ids and admin secret in production', () => { + expect(() => + validateEnv({ + NODE_ENV: 'production', + JWT_SECRET: 'super-secret', + DB_HOST: 'db.internal', + DB_USERNAME: 'postgres', + DB_PASSWORD: 'postgres', + DB_NAME: 'agentverse', + STELLAR_NETWORK: 'mainnet', + STELLAR_RPC_URL: 'https://rpc.stellar.example', + STELLAR_NETWORK_PASSPHRASE: 'Public Global Stellar Network ; September 2015', + SOROBAN_TOKEN_MINT_CONTRACT_ID: VALID_CONTRACT_ID, + SOROBAN_TOKEN_SALE_CONTRACT_ID: VALID_CONTRACT_ID, + CORS_ORIGINS: 'https://app.example', + }), + ).toThrow('STELLAR_ADMIN_SECRET_KEY is required in production'); + }); + it('allows disabling database seed on startup explicitly', () => { const env = validateEnv({ NODE_ENV: 'development', diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index b8305b1..dfa07c9 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,3 +1,4 @@ +import { StrKey } from '@stellar/stellar-sdk'; import { AppEnv, DEV_DEFAULTS } from './env.schema'; let validatedEnvCache: AppEnv | null = null; @@ -5,7 +6,14 @@ let validatedEnvCache: AppEnv | null = null; const REQUIRED_IN_PRODUCTION = { db: ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'] as const, jwt: ['JWT_SECRET'] as const, - stellar: ['STELLAR_NETWORK', 'STELLAR_RPC_URL', 'STELLAR_NETWORK_PASSPHRASE'] as const, + stellar: [ + 'STELLAR_NETWORK', + 'STELLAR_RPC_URL', + 'STELLAR_NETWORK_PASSPHRASE', + 'SOROBAN_TOKEN_MINT_CONTRACT_ID', + 'SOROBAN_TOKEN_SALE_CONTRACT_ID', + 'STELLAR_ADMIN_SECRET_KEY', + ] as const, cors: ['CORS_ORIGINS'] as const, }; @@ -80,6 +88,34 @@ function ensureProductionRequirement(env: NodeJS.ProcessEnv, key: string) { } } +function parseContractId(value: string | undefined, key: string, fallback: string): string { + const raw = value?.trim(); + + if (raw === undefined || raw === '') { + return fallback; + } + + if (!StrKey.isValidContract(raw)) { + throw new Error(`${key} must be a valid Soroban contract id (C...)`); + } + + return raw; +} + +function parseAdminSecretKey(value: string | undefined, fallback: string): string { + const raw = value?.trim(); + + if (raw === undefined || raw === '') { + return fallback; + } + + if (!StrKey.isValidEd25519SecretSeed(raw)) { + throw new Error('STELLAR_ADMIN_SECRET_KEY must be a valid Stellar secret seed (S...)'); + } + + return raw; +} + export function validateEnv(env: NodeJS.ProcessEnv): AppEnv { const isProduction = env.NODE_ENV === 'production'; @@ -117,10 +153,21 @@ export function validateEnv(env: NodeJS.ProcessEnv): AppEnv { rpcUrl: env.STELLAR_RPC_URL ?? DEV_DEFAULTS.stellar.rpcUrl, networkPassphrase: env.STELLAR_NETWORK_PASSPHRASE ?? DEV_DEFAULTS.stellar.networkPassphrase, contracts: { - tokenMint: env.SOROBAN_TOKEN_MINT_CONTRACT_ID ?? DEV_DEFAULTS.stellar.contracts.tokenMint, - tokenSale: env.SOROBAN_TOKEN_SALE_CONTRACT_ID ?? DEV_DEFAULTS.stellar.contracts.tokenSale, + tokenMint: parseContractId( + env.SOROBAN_TOKEN_MINT_CONTRACT_ID, + 'SOROBAN_TOKEN_MINT_CONTRACT_ID', + DEV_DEFAULTS.stellar.contracts.tokenMint, + ), + tokenSale: parseContractId( + env.SOROBAN_TOKEN_SALE_CONTRACT_ID, + 'SOROBAN_TOKEN_SALE_CONTRACT_ID', + DEV_DEFAULTS.stellar.contracts.tokenSale, + ), }, - adminSecretKey: env.STELLAR_ADMIN_SECRET_KEY ?? DEV_DEFAULTS.stellar.adminSecretKey, + adminSecretKey: parseAdminSecretKey( + env.STELLAR_ADMIN_SECRET_KEY, + DEV_DEFAULTS.stellar.adminSecretKey, + ), }, corsOrigins: parseCorsOrigins(env.CORS_ORIGINS, !isProduction), }; diff --git a/src/tokens/soroban-signing.service.spec.ts b/src/tokens/soroban-signing.service.spec.ts new file mode 100644 index 0000000..6877d70 --- /dev/null +++ b/src/tokens/soroban-signing.service.spec.ts @@ -0,0 +1,51 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Keypair } from '@stellar/stellar-sdk'; +import { sorobanConfig } from './config/soroban.config'; +import { SorobanSigningService } from './soroban-signing.service'; + +async function buildService(adminSecretKey: string) { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SorobanSigningService, + { + provide: sorobanConfig.KEY, + useValue: { adminSecretKey }, + }, + ], + }).compile(); + + return module.get(SorobanSigningService); +} + +describe('SorobanSigningService', () => { + it('derives the admin keypair from a configured secret', async () => { + const keypair = Keypair.random(); + const service = await buildService(keypair.secret()); + + service.onModuleInit(); + + expect(service.isAdminConfigured()).toBe(true); + expect(service.getAdminPublicKey()).toBe(keypair.publicKey()); + }); + + it('signs a transaction with the admin keypair', async () => { + const keypair = Keypair.random(); + const service = await buildService(keypair.secret()); + service.onModuleInit(); + + const tx = { sign: jest.fn() } as any; + service.signTransaction(tx); + + expect(tx.sign).toHaveBeenCalledTimes(1); + }); + + it('reports unconfigured and throws when the secret is missing', async () => { + const service = await buildService(''); + + service.onModuleInit(); + + expect(service.isAdminConfigured()).toBe(false); + expect(() => service.getAdminPublicKey()).toThrow(ServiceUnavailableException); + }); +}); diff --git a/src/tokens/soroban-signing.service.ts b/src/tokens/soroban-signing.service.ts new file mode 100644 index 0000000..ee29987 --- /dev/null +++ b/src/tokens/soroban-signing.service.ts @@ -0,0 +1,60 @@ +import { + Inject, + Injectable, + Logger, + OnModuleInit, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Keypair, Transaction } from '@stellar/stellar-sdk'; +import { sorobanConfig } from './config/soroban.config'; + +/** + * Holds the admin keypair derived from STELLAR_ADMIN_SECRET_KEY and signs + * Soroban transactions on its behalf. This is the only place a secret key is + * turned into a usable keypair; all token operations are admin-signed. + */ +@Injectable() +export class SorobanSigningService implements OnModuleInit { + private readonly logger = new Logger(SorobanSigningService.name); + private adminKeypair: Keypair | null = null; + + constructor( + @Inject(sorobanConfig.KEY) + private readonly config: { adminSecretKey: string }, + ) {} + + onModuleInit() { + const secret = this.config.adminSecretKey?.trim(); + + if (!secret) { + this.logger.warn( + 'STELLAR_ADMIN_SECRET_KEY is not configured. Token operations that require signing will be rejected.', + ); + return; + } + + // The seed format was already validated at env-validation time. + this.adminKeypair = Keypair.fromSecret(secret); + this.logger.log(`Admin signer configured: ${this.adminKeypair.publicKey()}`); + } + + isAdminConfigured(): boolean { + return this.adminKeypair !== null; + } + + getAdminPublicKey(): string { + return this.requireAdminKeypair().publicKey(); + } + + signTransaction(transaction: Transaction): void { + transaction.sign(this.requireAdminKeypair()); + } + + private requireAdminKeypair(): Keypair { + if (!this.adminKeypair) { + throw new ServiceUnavailableException('Admin signer is not configured'); + } + + return this.adminKeypair; + } +} diff --git a/src/tokens/soroban-tx.service.spec.ts b/src/tokens/soroban-tx.service.spec.ts new file mode 100644 index 0000000..5104803 --- /dev/null +++ b/src/tokens/soroban-tx.service.spec.ts @@ -0,0 +1,96 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { rpc } from '@stellar/stellar-sdk'; +import { sorobanConfig } from './config/soroban.config'; +import { SorobanSigningService } from './soroban-signing.service'; +import { SorobanTxService } from './soroban-tx.service'; + +jest.mock('@stellar/stellar-sdk', () => ({ + BASE_FEE: '100', + xdr: {}, + Contract: jest.fn().mockImplementation(() => ({ + call: jest.fn().mockReturnValue({ __op: true }), + })), + TransactionBuilder: jest.fn().mockImplementation(() => ({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ __tx: true }), + })), + rpc: { + Server: jest.fn(), + Api: { + GetTransactionStatus: { NOT_FOUND: 'NOT_FOUND', SUCCESS: 'SUCCESS', FAILED: 'FAILED' }, + }, + }, +})); + +describe('SorobanTxService', () => { + const rpcServerMock = rpc.Server as unknown as jest.Mock; + let rpcInstance: { + getAccount: jest.Mock; + prepareTransaction: jest.Mock; + sendTransaction: jest.Mock; + getTransaction: jest.Mock; + }; + let signing: { getAdminPublicKey: jest.Mock; signTransaction: jest.Mock }; + let service: SorobanTxService; + + beforeEach(async () => { + rpcInstance = { + getAccount: jest.fn().mockResolvedValue({ __account: true }), + prepareTransaction: jest.fn().mockResolvedValue({ __prepared: true }), + sendTransaction: jest.fn().mockResolvedValue({ status: 'PENDING', hash: 'HASH' }), + getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }), + }; + rpcServerMock.mockClear(); + rpcServerMock.mockImplementation(() => rpcInstance); + + signing = { + getAdminPublicKey: jest.fn().mockReturnValue('GADMIN'), + signTransaction: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SorobanTxService, + { + provide: sorobanConfig.KEY, + useValue: { rpcUrl: 'https://rpc.test', networkPassphrase: 'Test Network' }, + }, + { provide: SorobanSigningService, useValue: signing }, + ], + }).compile(); + + service = module.get(SorobanTxService); + service.onModuleInit(); + }); + + afterEach(() => jest.clearAllMocks()); + + it('builds, signs and submits a contract invocation', async () => { + const result = await service.invokeContract({ contractId: 'C123', method: 'mint', args: [] }); + + expect(rpcInstance.getAccount).toHaveBeenCalledWith('GADMIN'); + expect(rpcInstance.prepareTransaction).toHaveBeenCalledWith({ __tx: true }); + expect(signing.signTransaction).toHaveBeenCalledWith({ __prepared: true }); + expect(rpcInstance.sendTransaction).toHaveBeenCalledWith({ __prepared: true }); + expect(result).toEqual({ hash: 'HASH', status: 'SUCCESS' }); + }); + + it('throws when submission returns an error', async () => { + rpcInstance.sendTransaction.mockResolvedValue({ status: 'ERROR', hash: 'HASH', errorResult: {} }); + + await expect( + service.invokeContract({ contractId: 'C123', method: 'mint', args: [] }), + ).rejects.toBeInstanceOf(InternalServerErrorException); + expect(rpcInstance.getTransaction).not.toHaveBeenCalled(); + }); + + it('throws when the transaction does not reach SUCCESS', async () => { + rpcInstance.getTransaction.mockResolvedValue({ status: 'FAILED' }); + + await expect( + service.invokeContract({ contractId: 'C123', method: 'mint', args: [] }), + ).rejects.toBeInstanceOf(InternalServerErrorException); + }); +}); diff --git a/src/tokens/soroban-tx.service.ts b/src/tokens/soroban-tx.service.ts new file mode 100644 index 0000000..940a2aa --- /dev/null +++ b/src/tokens/soroban-tx.service.ts @@ -0,0 +1,105 @@ +import { + Inject, + Injectable, + InternalServerErrorException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { + BASE_FEE, + Contract, + TransactionBuilder, + rpc, + xdr, +} from '@stellar/stellar-sdk'; +import { sorobanConfig } from './config/soroban.config'; +import { SorobanSigningService } from './soroban-signing.service'; + +export interface InvokeContractParams { + contractId: string; + method: string; + args: xdr.ScVal[]; +} + +export interface InvokeContractResult { + hash: string; + status: string; +} + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_ATTEMPTS = 30; + +/** + * Generic admin-signed Soroban contract invocation: build -> simulate/assemble + * -> sign with the admin keypair -> submit -> poll until the transaction reaches + * a terminal state. Shared by every token operation. + */ +@Injectable() +export class SorobanTxService implements OnModuleInit { + private readonly logger = new Logger(SorobanTxService.name); + private rpcServer: rpc.Server; + + constructor( + @Inject(sorobanConfig.KEY) + private readonly config: { rpcUrl: string; networkPassphrase: string }, + private readonly signing: SorobanSigningService, + ) {} + + onModuleInit() { + this.rpcServer = new rpc.Server(this.config.rpcUrl); + this.logger.log(`Conectado a Stellar RPC: ${this.config.rpcUrl}`); + } + + async invokeContract({ contractId, method, args }: InvokeContractParams): Promise { + const adminPublicKey = this.signing.getAdminPublicKey(); + const account = await this.rpcServer.getAccount(adminPublicKey); + + const operation = new Contract(contractId).call(method, ...args); + + const built = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + // Simulate to populate the Soroban footprint and resource fees, then sign. + const prepared = await this.rpcServer.prepareTransaction(built); + this.signing.signTransaction(prepared); + + const send = await this.rpcServer.sendTransaction(prepared); + + if (send.status === 'ERROR') { + this.logger.error(`Transaction submission failed for ${method}: ${JSON.stringify(send.errorResult)}`); + throw new InternalServerErrorException(`Failed to submit ${method} transaction`); + } + + const result = await this.pollTransaction(send.hash); + + if (result.status !== rpc.Api.GetTransactionStatus.SUCCESS) { + this.logger.error(`Transaction ${send.hash} did not succeed: ${result.status}`); + throw new InternalServerErrorException(`Transaction ${send.hash} ${result.status}`); + } + + return { hash: send.hash, status: result.status }; + } + + private async pollTransaction(hash: string): Promise { + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { + const response = await this.rpcServer.getTransaction(hash); + + if (response.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) { + return response; + } + + await this.delay(POLL_INTERVAL_MS); + } + + throw new InternalServerErrorException(`Timed out waiting for transaction ${hash}`); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/tokens/tokens.module.ts b/src/tokens/tokens.module.ts index 137c373..7ace8df 100644 --- a/src/tokens/tokens.module.ts +++ b/src/tokens/tokens.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TokensService } from './tokens.service'; +import { SorobanSigningService } from './soroban-signing.service'; +import { SorobanTxService } from './soroban-tx.service'; import { sorobanConfig } from './config/soroban.config'; @Module({ imports: [ConfigModule.forFeature(sorobanConfig)], - providers: [TokensService], - exports: [TokensService], + providers: [TokensService, SorobanSigningService, SorobanTxService], + exports: [TokensService, SorobanSigningService, SorobanTxService], }) export class TokensModule {} diff --git a/src/tokens/tokens.service.spec.ts b/src/tokens/tokens.service.spec.ts index 8dbddb1..73db6da 100644 --- a/src/tokens/tokens.service.spec.ts +++ b/src/tokens/tokens.service.spec.ts @@ -1,123 +1,96 @@ +import { ServiceUnavailableException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as StellarSdk from '@stellar/stellar-sdk'; +import { Keypair, StrKey } from '@stellar/stellar-sdk'; import { sorobanConfig } from './config/soroban.config'; +import { SorobanTxService } from './soroban-tx.service'; import { TokensService } from './tokens.service'; -jest.mock('@stellar/stellar-sdk', () => ({ - rpc: { - Server: jest.fn(), - }, -})); +const VALID_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); +const RECIPIENT = Keypair.random().publicKey(); describe('TokensService', () => { let service: TokensService; - const rpcServerMock = StellarSdk.rpc.Server as jest.Mock; + let invokeContract: jest.Mock; - beforeEach(async () => { - rpcServerMock.mockClear(); + async function buildService(contracts: { tokenMint: string; tokenSale: string }) { + invokeContract = jest.fn().mockResolvedValue({ hash: 'TXHASH', status: 'SUCCESS' }); const module: TestingModule = await Test.createTestingModule({ providers: [ TokensService, { provide: sorobanConfig.KEY, - useValue: { - network: 'testnet', - rpcUrl: 'https://rpc.test', - networkPassphrase: 'Test Network', - contracts: { - tokenMint: 'mint-contract', - tokenSale: 'sale-contract', - }, - adminSecretKey: 'SSECRET', - }, + useValue: { contracts }, + }, + { + provide: SorobanTxService, + useValue: { invokeContract }, }, ], }).compile(); - service = module.get(TokensService); + return module.get(TokensService); + } + + beforeEach(async () => { + service = await buildService({ + tokenMint: VALID_CONTRACT_ID, + tokenSale: VALID_CONTRACT_ID, + }); }); afterEach(() => { jest.clearAllMocks(); }); - it('initializes the Stellar RPC server on module init', () => { - service.onModuleInit(); - - expect(rpcServerMock).toHaveBeenCalledWith('https://rpc.test'); - expect((service as any).rpc).toBeDefined(); - expect((service as any).networkPassphrase).toBe('Test Network'); - }); - - it('returns an error when mint contract id is missing', async () => { - const module = await Test.createTestingModule({ - providers: [ - TokensService, - { - provide: sorobanConfig.KEY, - useValue: { - network: 'testnet', - rpcUrl: 'https://rpc.test', - networkPassphrase: 'Test Network', - contracts: { tokenMint: '', tokenSale: 'sale-contract' }, - adminSecretKey: 'SSECRET', - }, - }, - ], - }).compile(); - const missingMintService = module.get(TokensService); + it('mints tokens via an admin-signed contract invocation', async () => { + const result = await service.mintTokens(RECIPIENT, '100'); - await expect(missingMintService.mintTokens('GDEST', '100')).resolves.toEqual({ - error: 'Contract ID not configured', + expect(invokeContract).toHaveBeenCalledWith( + expect.objectContaining({ contractId: VALID_CONTRACT_ID, method: 'mint' }), + ); + expect(invokeContract.mock.calls[0][0].args).toHaveLength(2); + expect(result).toEqual({ + hash: 'TXHASH', + status: 'SUCCESS', + operation: 'mint', + to: RECIPIENT, + amount: '100', }); }); - it('simulates mint success when contract id is configured', async () => { - service.onModuleInit(); + it('sells tokens via an admin-signed contract invocation', async () => { + const result = await service.sellTokens(RECIPIENT, '10', '2'); - await expect(service.mintTokens('GDEST', '100')).resolves.toEqual({ - status: 'simulated_success', - contractId: 'mint-contract', - operation: 'mint', - to: 'GDEST', - amount: '100', + expect(invokeContract).toHaveBeenCalledWith( + expect.objectContaining({ contractId: VALID_CONTRACT_ID, method: 'sell' }), + ); + expect(invokeContract.mock.calls[0][0].args).toHaveLength(3); + expect(result).toEqual({ + hash: 'TXHASH', + status: 'SUCCESS', + operation: 'sell', + seller: RECIPIENT, + amount: '10', + price: '2', }); }); - it('returns an error when sell contract id is missing', async () => { - const module = await Test.createTestingModule({ - providers: [ - TokensService, - { - provide: sorobanConfig.KEY, - useValue: { - network: 'testnet', - rpcUrl: 'https://rpc.test', - networkPassphrase: 'Test Network', - contracts: { tokenMint: 'mint-contract', tokenSale: 'PLACEHOLDER_SALE' }, - adminSecretKey: 'SSECRET', - }, - }, - ], - }).compile(); - const missingSaleService = module.get(TokensService); + it('rejects mint when the mint contract id is not configured', async () => { + const unconfigured = await buildService({ tokenMint: '', tokenSale: VALID_CONTRACT_ID }); - await expect(missingSaleService.sellTokens('GSELLER', '10', '2')).resolves.toEqual({ - error: 'Contract ID not configured', - }); + await expect(unconfigured.mintTokens(RECIPIENT, '100')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(invokeContract).not.toHaveBeenCalled(); }); - it('simulates sell success when contract id is configured', async () => { - service.onModuleInit(); + it('rejects sell when the sale contract id is invalid', async () => { + const unconfigured = await buildService({ tokenMint: VALID_CONTRACT_ID, tokenSale: 'PLACEHOLDER' }); - await expect(service.sellTokens('GSELLER', '10', '2')).resolves.toEqual({ - status: 'simulated_success', - contractId: 'sale-contract', - operation: 'sell', - seller: 'GSELLER', - amount: '10', - price: '2', - }); + await expect(unconfigured.sellTokens(RECIPIENT, '10', '2')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(invokeContract).not.toHaveBeenCalled(); }); }); diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index efd1a7a..39a71c8 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,85 +1,76 @@ -import { Injectable, Logger, OnModuleInit, Inject } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Address, StrKey, nativeToScVal } from '@stellar/stellar-sdk'; import { sorobanConfig } from './config/soroban.config'; -import * as StellarSdk from '@stellar/stellar-sdk'; +import { SorobanTxService } from './soroban-tx.service'; @Injectable() -export class TokensService implements OnModuleInit { +export class TokensService { private readonly logger = new Logger(TokensService.name); - private rpc: StellarSdk.rpc.Server; - private networkPassphrase: string; constructor( @Inject(sorobanConfig.KEY) - private config: { - network: string; - rpcUrl: string; - networkPassphrase: string; + private readonly config: { contracts: { tokenMint: string; tokenSale: string }; - adminSecretKey: string; }, + private readonly tx: SorobanTxService, ) {} - onModuleInit() { - this.rpc = new StellarSdk.rpc.Server(this.config.rpcUrl); - this.networkPassphrase = this.config.networkPassphrase; - this.logger.log(`Conectado a Stellar RPC: ${this.config.rpcUrl}`); - } - /** - * Invoca el contrato de minteo de tokens. + * Invoca el contrato de minteo de tokens (firmado por el admin). * @param to Dirección de destino (Stellar Address) * @param amount Cantidad a mintear */ async mintTokens(to: string, amount: string) { const contractId = this.config.contracts.tokenMint; - - if (!contractId || contractId.includes('PLACEHOLDER')) { - this.logger.warn(`El ID del contrato de Mint 'tokenMint' no está configurado.`); - return { error: 'Contract ID not configured' }; - } + this.assertContractConfigured(contractId, 'tokenMint'); this.logger.log(`Invocando Contrato Mint (${contractId}) -> mint(to: ${to}, amount: ${amount})`); - // LOGICA DE INVOCACIÓN (PLACEHOLDER) - // Aquí debes implementar la llamada real usando StellarSdk - // Ejemplo: - // const tx = await this.rpc.sendTransaction(...) - - return { - status: 'simulated_success', - contractId, - operation: 'mint', - to, - amount - }; + const result = await this.tx.invokeContract({ + contractId, + method: 'mint', + args: [Address.fromString(to).toScVal(), nativeToScVal(amount, { type: 'i128' })], + }); + + return { ...result, operation: 'mint', to, amount }; } /** - * Invoca el contrato de venta de tokens. + * Invoca el contrato de venta de tokens (firmado por el admin). * @param seller Vendedor * @param amount Cantidad * @param price Precio */ async sellTokens(seller: string, amount: string, price: string) { - const contractId = this.config.contracts.tokenSale; + const contractId = this.config.contracts.tokenSale; + this.assertContractConfigured(contractId, 'tokenSale'); - if (!contractId || contractId.includes('PLACEHOLDER')) { - this.logger.warn(`El ID del contrato de Venta 'tokenSale' no está configurado.`); - return { error: 'Contract ID not configured' }; - } + this.logger.log( + `Invocando Contrato Venta (${contractId}) -> sell(seller: ${seller}, amount: ${amount}, price: ${price})`, + ); + + const result = await this.tx.invokeContract({ + contractId, + method: 'sell', + args: [ + Address.fromString(seller).toScVal(), + nativeToScVal(amount, { type: 'i128' }), + nativeToScVal(price, { type: 'i128' }), + ], + }); - this.logger.log(`Invocando Contrato Venta (${contractId}) -> sell(seller: ${seller}, amount: ${amount}, price: ${price})`); + return { ...result, operation: 'sell', seller, amount, price }; + } - // LOGICA DE INVOCACIÓN (PLACEHOLDER) - // Aquí debes implementar la llamada real usando StellarSdk - - return { - status: 'simulated_success', - contractId, - operation: 'sell', - seller, - amount, - price - }; + private assertContractConfigured(contractId: string, label: string): void { + if (!contractId || !StrKey.isValidContract(contractId)) { + this.logger.warn(`El ID del contrato '${label}' no está configurado o es inválido.`); + throw new ServiceUnavailableException(`${label} contract not configured`); + } } }