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
10 changes: 6 additions & 4 deletions backend/src/services/sorobanService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ export interface ChainStream {
isActive: boolean;
}

function decodeI128(val: xdr.ScVal): string {
export function decodeI128(val: xdr.ScVal): string {
const parts = val.i128();
const hi = BigInt.asIntN(64, BigInt(parts.hi().toString()));
const lo = BigInt.asUintN(64, BigInt(parts.lo().toString()));
return ((hi << 64n) | lo).toString();
}

function decodeAddress(val: xdr.ScVal): string {
export function decodeAddress(val: xdr.ScVal): string {
const addr = val.address();
if (addr.switch().value === xdr.ScAddressType.scAddressTypeAccount().value) {
return StrKey.encodeEd25519PublicKey(addr.accountId().ed25519());
Expand All @@ -51,8 +51,10 @@ async function simulateContractCall(method: string, args: xdr.ScVal[]): Promise<
const op = contract.call(method, ...args);

const tx = new TransactionBuilder(
// Read-only simulations don't consume a real source account; use a valid
// all-zero placeholder so Account construction never throws.
new Account(
'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN',
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
'0'
),
{
Expand All @@ -77,7 +79,7 @@ async function simulateContractCall(method: string, args: xdr.ScVal[]): Promise<
return simSuccess.result!.retval;
}

async function submitContractCall(method: string, args: xdr.ScVal[], senderSecret: string): Promise<string> {
export async function submitContractCall(method: string, args: xdr.ScVal[], senderSecret: string): Promise<string> {
if (!CONTRACT_ID) throw new Error('CONTRACT_ID not set');

const keypair = Keypair.fromSecret(senderSecret);
Expand Down
239 changes: 235 additions & 4 deletions backend/tests/soroban.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,247 @@
import { describe, it, expect } from 'vitest';
import { isStale } from '../src/services/sorobanService.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
Account,
Keypair,
StrKey,
nativeToScVal,
rpc,
xdr,
} from '@stellar/stellar-sdk';

const mocks = vi.hoisted(() => {
const server = {
getAccount: vi.fn(),
simulateTransaction: vi.fn(),
sendTransaction: vi.fn(),
};

return {
server,
serverCtor: vi.fn(() => server),
assembleTransaction: vi.fn(),
isSimulationError: vi.fn(),
};
});

vi.mock('@stellar/stellar-sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@stellar/stellar-sdk')>();

return {
...actual,
rpc: {
...actual.rpc,
Server: mocks.serverCtor,
assembleTransaction: mocks.assembleTransaction,
Api: {
...actual.rpc.Api,
isSimulationError: mocks.isSimulationError,
},
},
};
});

vi.mock('../src/logger.js', () => ({
default: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));

const contractId = StrKey.encodeContract(Buffer.alloc(32, 1));

function mapEntry(key: string, val: xdr.ScVal): xdr.ScMapEntry {
return new xdr.ScMapEntry({
key: xdr.ScVal.scvSymbol(key),
val,
});
}

function mapVal(entries: Array<[string, xdr.ScVal]>): xdr.ScVal {
return xdr.ScVal.scvMap(entries.map(([key, val]) => mapEntry(key, val)));
}

function simulationSuccess(retval: xdr.ScVal): rpc.Api.SimulateTransactionSuccessResponse {
return {
result: { retval },
} as rpc.Api.SimulateTransactionSuccessResponse;
}

async function importService(env: Record<string, string | undefined> = {}) {
vi.resetModules();

if (env.STREAM_CONTRACT_ID === undefined) {
process.env.STREAM_CONTRACT_ID = contractId;
} else {
process.env.STREAM_CONTRACT_ID = env.STREAM_CONTRACT_ID;
}

if (env.KEEPER_SECRET_KEY === undefined) {
delete process.env.KEEPER_SECRET_KEY;
} else {
process.env.KEEPER_SECRET_KEY = env.KEEPER_SECRET_KEY;
}

process.env.SOROBAN_RPC_URL = 'https://rpc.test';

return import('../src/services/sorobanService.js');
}

describe('Soroban Service', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isSimulationError.mockReturnValue(false);
});

afterEach(() => {
delete process.env.STREAM_CONTRACT_ID;
delete process.env.KEEPER_SECRET_KEY;
delete process.env.SOROBAN_RPC_URL;
});

describe('isStale', () => {
it('should return true if updated more than 30s ago', () => {
it('should return true if updated more than 30s ago', async () => {
const { isStale } = await importService();

const longAgo = new Date(Date.now() - 31000);
expect(isStale(longAgo)).toBe(true);
});

it('should return false if updated recently', () => {
it('should return false if updated recently', async () => {
const { isStale } = await importService();

const recently = new Date(Date.now() - 5000);
expect(isStale(recently)).toBe(false);
});
});

describe('submitContractCall', () => {
it('throws when simulation returns an error', async () => {
const { submitContractCall } = await importService();
const sender = Keypair.random();
const simulation = { error: 'contract trapped' };

mocks.server.getAccount.mockResolvedValue(new Account(sender.publicKey(), '1'));
mocks.server.simulateTransaction.mockResolvedValue(simulation);
mocks.isSimulationError.mockReturnValue(true);

await expect(
submitContractCall('cancel_stream', [nativeToScVal(1, { type: 'u64' })], sender.secret())
).rejects.toThrow('Simulation failed: contract trapped');
expect(mocks.server.sendTransaction).not.toHaveBeenCalled();
});

it('throws when sendTransaction returns ERROR', async () => {
const { submitContractCall } = await importService();
const sender = Keypair.random();
const assembledTx = { sign: vi.fn() };

mocks.server.getAccount.mockResolvedValue(new Account(sender.publicKey(), '1'));
mocks.server.simulateTransaction.mockResolvedValue(simulationSuccess(nativeToScVal(1)));
mocks.assembleTransaction.mockReturnValue({ build: () => assembledTx });
mocks.server.sendTransaction.mockResolvedValue({
status: 'ERROR',
errorResult: 'tx failed',
});

await expect(
submitContractCall('cancel_stream', [nativeToScVal(1, { type: 'u64' })], sender.secret())
).rejects.toThrow('Transaction failed: "tx failed"');
expect(assembledTx.sign).toHaveBeenCalledWith(sender);
});
});

describe('chain reads', () => {
it('decodes getStreamFromChain response', async () => {
const { getStreamFromChain } = await importService();
const sender = Keypair.random().publicKey();
const recipient = Keypair.random().publicKey();
const tokenAddress = StrKey.encodeContract(Buffer.alloc(32, 2));

mocks.server.simulateTransaction.mockResolvedValue(
simulationSuccess(
mapVal([
['sender', nativeToScVal(sender, { type: 'address' })],
['recipient', nativeToScVal(recipient, { type: 'address' })],
['token_address', nativeToScVal(tokenAddress, { type: 'address' })],
['rate_per_second', nativeToScVal(25n, { type: 'i128' })],
['deposited_amount', nativeToScVal(1_000n, { type: 'i128' })],
['withdrawn_amount', nativeToScVal(125n, { type: 'i128' })],
['start_time', nativeToScVal(1_700_000_000, { type: 'u64' })],
['is_active', nativeToScVal(true)],
])
)
);

await expect(getStreamFromChain(7)).resolves.toEqual({
streamId: 7,
sender,
recipient,
tokenAddress,
ratePerSecond: '25',
depositedAmount: '1000',
withdrawnAmount: '125',
startTime: 1_700_000_000,
isActive: true,
});
});

it('returns null when getStreamFromChain decoding fails', async () => {
const { getStreamFromChain } = await importService();

mocks.server.simulateTransaction.mockResolvedValue(
simulationSuccess(mapVal([['sender', nativeToScVal('not-an-address')]]))
);

await expect(getStreamFromChain(8)).resolves.toBeNull();
});

it('decodes getClaimableFromChain response', async () => {
const { getClaimableFromChain } = await importService();

mocks.server.simulateTransaction.mockResolvedValue(
simulationSuccess(nativeToScVal(99n, { type: 'i128' }))
);

await expect(getClaimableFromChain(9)).resolves.toBe('99');
});

it('returns null when getClaimableFromChain decoding fails', async () => {
const { getClaimableFromChain } = await importService();

mocks.server.simulateTransaction.mockResolvedValue(simulationSuccess(nativeToScVal(true)));

await expect(getClaimableFromChain(10)).resolves.toBeNull();
});
});

describe('decoders', () => {
it('decodes positive and negative i128 values', async () => {
const { decodeI128 } = await importService();

expect(decodeI128(nativeToScVal(123n, { type: 'i128' }))).toBe('123');
expect(decodeI128(nativeToScVal(-123n, { type: 'i128' }))).toBe('-123');
});

it('decodes account and contract addresses', async () => {
const { decodeAddress } = await importService();
const account = Keypair.random().publicKey();
const contract = StrKey.encodeContract(Buffer.alloc(32, 3));

expect(decodeAddress(nativeToScVal(account, { type: 'address' }))).toBe(account);
expect(decodeAddress(nativeToScVal(contract, { type: 'address' }))).toBe(contract);
});
});

describe('topUpStream', () => {
it('throws when KEEPER_SECRET_KEY is unset', async () => {
const { topUpStream } = await importService({ KEEPER_SECRET_KEY: undefined });

await expect(topUpStream(1, 100n, Keypair.random().publicKey())).rejects.toThrow(
'KEEPER_SECRET_KEY not configured'
);
expect(mocks.server.sendTransaction).not.toHaveBeenCalled();
});
});
});
Loading