diff --git a/src/transactions/builder.ts b/src/transactions/builder.ts new file mode 100644 index 0000000..05badb8 --- /dev/null +++ b/src/transactions/builder.ts @@ -0,0 +1,22 @@ +import crypto from 'crypto'; + +export type Memo = + | { type: 'MEMO_TEXT'; value: string } + | { type: 'MEMO_HASH'; value: Buffer }; + +export function encodeMemo(input: string | Record): Memo { + if (typeof input === 'string') { + const bytes = Buffer.byteLength(input, 'utf8'); + if (bytes <= 28) return { type: 'MEMO_TEXT', value: input }; + throw new Error('Memo text exceeds 28 bytes'); + } + + const json = JSON.stringify(input); + const b64 = Buffer.from(json, 'utf8').toString('base64'); + if (Buffer.byteLength(b64, 'utf8') <= 28) { + return { type: 'MEMO_TEXT', value: b64 }; + } + + const hash = crypto.createHash('sha256').update(b64, 'utf8').digest(); + return { type: 'MEMO_HASH', value: hash }; +} diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 04d4874..47b091c 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,2 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function buildMultisigTransaction(..._args: unknown[]): unknown { return undefined; } + +export { encodeMemo } from './builder'; +export { fetchSequenceNumber } from './sequence'; diff --git a/src/transactions/sequence.ts b/src/transactions/sequence.ts new file mode 100644 index 0000000..b578fdf --- /dev/null +++ b/src/transactions/sequence.ts @@ -0,0 +1,25 @@ +import { Server } from '@stellar/stellar-sdk'; +import { TESTNET_HORIZON_URL } from '../utils/constants'; +import { AccountNotFoundError } from '../utils/errors'; + +const cache = new Map(); +const TTL = 5000; // 5 seconds + +export async function fetchSequenceNumber(accountId: string): Promise { + const now = Date.now(); + const cached = cache.get(accountId); + if (cached && now - cached.ts < TTL) return cached.sequence; + + const server = new Server(TESTNET_HORIZON_URL); + try { + const res: any = await server.accounts().accountId(accountId).call(); + const sequence = String(res.sequence); + cache.set(accountId, { sequence, ts: now }); + return sequence; + } catch (err: any) { + if (err && err.response && err.response.status === 404) { + throw new AccountNotFoundError(accountId); + } + throw err; + } +} diff --git a/tests/unit/transactions/builder.test.ts b/tests/unit/transactions/builder.test.ts new file mode 100644 index 0000000..ad0325e --- /dev/null +++ b/tests/unit/transactions/builder.test.ts @@ -0,0 +1,29 @@ +import { encodeMemo } from '../../../src/transactions'; + +describe('encodeMemo', () => { + it('encodes short string as MEMO_TEXT', () => { + const memo = encodeMemo('hello'); + expect(memo).toEqual({ type: 'MEMO_TEXT', value: 'hello' }); + }); + + it('throws for string longer than 28 bytes', () => { + const long = 'a'.repeat(29); + expect(() => encodeMemo(long)).toThrow(); + }); + + it('encodes small object as base64 MEMO_TEXT', () => { + const obj = { a: 1 }; + const memo = encodeMemo(obj); + expect(memo.type).toBe('MEMO_TEXT'); + expect(typeof (memo as any).value).toBe('string'); + expect((memo as any).value.length).toBeGreaterThan(0); + }); + + it('hashes large object to MEMO_HASH', () => { + const obj = { data: 'x'.repeat(200) }; + const memo = encodeMemo(obj); + expect(memo.type).toBe('MEMO_HASH'); + expect(Buffer.isBuffer((memo as any).value)).toBe(true); + expect(((memo as any).value as Buffer).length).toBe(32); + }); +}); diff --git a/tests/unit/transactions/sequence.test.ts b/tests/unit/transactions/sequence.test.ts new file mode 100644 index 0000000..f93b4da --- /dev/null +++ b/tests/unit/transactions/sequence.test.ts @@ -0,0 +1,45 @@ +import { fetchSequenceNumber } from '../../../src/transactions'; +import { Server } from '@stellar/stellar-sdk'; +import { AccountNotFoundError } from '../../../src/utils/errors'; + +jest.mock('@stellar/stellar-sdk'); + +describe('fetchSequenceNumber', () => { + const accountId = 'GABC123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns sequence from horizon', async () => { + const mockCall = jest.fn().mockResolvedValue({ sequence: '42' }); + // @ts-ignore + Server.mockImplementation(() => ({ accounts: () => ({ accountId: () => ({ call: mockCall }) }) })); + + const seq = await fetchSequenceNumber(accountId); + expect(seq).toBe('42'); + expect(mockCall).toHaveBeenCalled(); + }); + + it('caches result for 5s', async () => { + const mockCall = jest.fn().mockResolvedValue({ sequence: '100' }); + // @ts-ignore + Server.mockImplementation(() => ({ accounts: () => ({ accountId: () => ({ call: mockCall }) }) })); + + const a = await fetchSequenceNumber(accountId); + const b = await fetchSequenceNumber(accountId); + expect(a).toBe('100'); + expect(b).toBe('100'); + expect(mockCall).toHaveBeenCalledTimes(1); + }); + + it('throws AccountNotFoundError on 404', async () => { + const err: any = new Error('not found'); + err.response = { status: 404 }; + const mockCall = jest.fn().mockRejectedValue(err); + // @ts-ignore + Server.mockImplementation(() => ({ accounts: () => ({ accountId: () => ({ call: mockCall }) }) })); + + await expect(fetchSequenceNumber(accountId)).rejects.toBeInstanceOf(AccountNotFoundError); + }); +});