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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['*'],
Expand Down
56 changes: 56 additions & 0 deletions src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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',
Expand Down
55 changes: 51 additions & 4 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { StrKey } from '@stellar/stellar-sdk';
import { AppEnv, DEV_DEFAULTS } from './env.schema';

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,
};

Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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),
};
Expand Down
51 changes: 51 additions & 0 deletions src/tokens/soroban-signing.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 60 additions & 0 deletions src/tokens/soroban-signing.service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
96 changes: 96 additions & 0 deletions src/tokens/soroban-tx.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading