src/signer/runtime.test.ts
v0.1.0 · 13.7 KB
// Offline optional runtime coverage; no keys, accounts, or response data are sourced from a live user.
import { randomBytes } from 'node:crypto';
import { mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
import { base58Decode, base58Encode } from '../address/base58.js';
import { delegate, exchangeKeys, globalBytes, headerBytes, market, owner } from '../testing/fixtures.js';
let S: Record<string, any>;
beforeAll(async () => {
const runtimePath = '../../signer/' + 'sidecar.mjs';
S = await import(runtimePath);
});
function env() {
return {
PHX_SIGNER_BOT_PUBKEY: delegate,
PHX_SIGNER_AUTHORITY: owner,
PHX_SIGNER_TOKEN: base58Encode(randomBytes(32)),
PHX_SIGNER_RPC_URL: 'https://rpc.example.invalid',
PHX_SIGNER_CLIENT_ORDER_NAMESPACE: 'example',
PHX_SIGNER_PORT: '9000',
PHX_SIGNER_PRIORITY_FEE_MIN: '0',
PHX_SIGNER_PRIORITY_FEE_MAX: '500',
PHX_SIGNER_IOC_SLOT_OFFSET_MAX: '10',
PHX_SIGNER_INTENT_TTL_MS: '60000',
PHX_SIGNER_INTENT_TOMBSTONE_MS: '120000',
PHX_SIGNER_COLLATERAL_MAX_AGE_MS: '10000',
PHX_SIGNER_COLLATERAL_REFRESH_MS: '1000',
PHX_SIGNER_CU_ORDER: '100000',
PHX_SIGNER_CU_CANCEL: '100000',
PHX_SIGNER_CU_CANCEL_PER_ID: '0',
PHX_SIGNER_PRIORITY_FEE_PERCENTILE: '50',
PHX_SIGNER_IOC_SLOT_OFFSET: '5',
PHX_SIGNER_HTTP_WAIT_MS: '100',
PHX_SIGNER_MAX_INFLIGHT: '4',
PHX_SIGNER_RPC_TIMEOUT_MS: '1000',
PHX_SIGNER_CONFIRM_POLL_MS: '1',
PHX_SIGNER_REBROADCAST_MS: '100',
PHX_SIGNER_CONFIRM_MAX_MS: '200',
PHX_SIGNER_EXPIRY_MARGIN_BLOCKS: '2',
PHX_SIGNER_EXPIRY_RECHECK_MS: '0',
PHX_SIGNER_RESOLVE_MS: '1000',
PHX_SIGNER_META_REFRESH_MS: '100000',
PHX_SIGNER_HEADER_SHARE_MS: '0',
PHX_SIGNER_HEADER_HEARTBEAT_MS: '100000',
PHX_SIGNER_COLLATERAL_LAZY_MAX_AGE_MS: '0',
PHX_SIGNER_SOL_REFRESH_MS: '100000',
PHX_SIGNER_SOL_MAX_AGE_MS: '200000',
PHX_SIGNER_GLOBAL_CONFIG_TTL_MS: '0',
PHX_SIGNER_MIN_SOL_LAMPORTS: '0',
};
}
const req = () => ({
op: 'order',
intentId: 'example-intent-1',
symbol: 'BTC',
kind: 'ioc',
side: 'bid',
priceInTicks: 800n,
numBaseLots: 3n,
reduceOnly: false,
lastValidSlotOffset: 5,
});
describe('optional signing runtime', () => {
it('requires caller policy and defaults to readonly', () => {
const e = env();
const c = S.parseConfig(e);
expect(c.readonly).toBe(true);
expect(c.clientOrderNamespace).toBe('example');
delete (e as Record<string, string>).PHX_SIGNER_PORT;
expect(() => S.parseConfig(e)).toThrow('PHX_SIGNER_PORT');
});
it('requires a durable journal before accepting write mode', () => {
expect(() => S.parseConfig({ ...env(), PHX_SIGNER_READONLY: 'false' })).toThrow();
expect(
S.parseConfig({ ...env(), PHX_SIGNER_READONLY: 'false', PHX_SIGNER_JOURNAL: 'intent-journal.jsonl' }).readonly,
).toBe(false);
});
it('refuses programmatic write mode without an open journal or matching signer', () => {
const config = S.parseConfig({
...env(),
PHX_SIGNER_READONLY: 'false',
PHX_SIGNER_JOURNAL: 'intent-journal.jsonl',
});
const signer = { address: delegate, signTransactions: async () => [] };
expect(() => S.createSidecar({ config, signer, rpc: {}, api: {} })).toThrow('open durable');
const journal = { load: () => [], append: () => {}, rewrite: () => {}, close: () => {} };
expect(() => S.createSidecar({ config, signer: { ...signer, address: owner }, journal, rpc: {}, api: {} })).toThrow(
'matching',
);
});
it('enforces independent owner/delegate and authenticated loopback binding', () => {
expect(() => S.parseConfig({ ...env(), PHX_SIGNER_AUTHORITY: delegate })).toThrow();
expect(() => S.parseConfig({ ...env(), PHX_SIGNER_BIND: '0.0.0.0' })).toThrow();
expect(() => S.parseConfig({ ...env(), PHX_SIGNER_TOKEN: 'small' })).toThrow();
});
it('uses exact tick/lot strings and makes IoC partial-fill rather than FOK', () => {
const c = S.parseConfig(env());
const r = S.normalizeOrderRequest(
{
intentId: 'example-intent-1',
symbol: 'BTC',
kind: 'ioc',
side: 'bid',
reduceOnly: false,
priceInTicks: '800',
numBaseLots: '3',
},
c,
);
const p = S.buildOrderPacket(r, { lastValidSlot: 20n });
expect(p.minBaseLotsToFill).toBe(0n);
expect(p.minQuoteLotsToFill).toBe(0n);
expect(p.cancelExisting).toBe(false);
expect(p.lastValidSlot).toBe(20n);
expect(p.priceInTicks).toBe(800n);
});
it('rejects numeric u64 values before instruction construction', () => {
const c = S.parseConfig(env());
expect(() => S.normalizeOrderRequest({ ...req(), priceInTicks: 800, numBaseLots: '3' }, c)).toThrow();
expect(() => S.parseU64(900, 'ticks')).toThrow();
});
it('makes the public on-chain client id namespace explicit', () => {
expect(() => S.clientOrderIdFor('example-intent-1')).toThrow();
expect(S.clientOrderIdFor('example-intent-1', 'one')).not.toBe(S.clientOrderIdFor('example-intent-1', 'two'));
});
it('preserves PostOnly slide=false and reduce-only flags', () => {
const p = S.buildOrderPacket({ ...req(), kind: 'postOnly', reduceOnly: true });
expect(p.slide).toBe(false);
expect(p.cancelExisting).toBe(false);
expect(p.orderFlags).toBe(128);
});
it('builds protocol instructions with explicit mainnet addresses', () => {
const ix = S.buildPhoenixInstruction(req(), {
keys: exchangeKeys,
market: market(),
signer: delegate,
traderAccount: owner,
lastValidSlot: 20n,
clientOrderNamespace: 'example',
});
expect(String(ix.programAddress)).toBe(S.PROGRAM_ADDRESS);
expect(ix.data.length).toBeGreaterThan(0);
});
it('checks cancel count and transaction wire size', () => {
const c = S.parseConfig(env());
const ids = Array.from({ length: 31 }, (_, i) => ({ priceInTicks: String(i + 1), seq: String(i + 1) }));
expect(() => S.normalizeCancelRequest({ intentId: 'example-cancel-1', symbol: 'BTC', ids }, c)).toThrow();
});
it('decodes return data from the matching engine side', () => {
const data = new Uint8Array(64);
const d = new DataView(data.buffer);
d.setBigUint64(16, 600n, true);
d.setBigUint64(40, 2n, true);
const r = S.decodeCpiResponse(data, 'bid');
expect(r.filledBaseLots).toBe('2');
expect(r.filledQuoteLots).toBe('600');
});
it('fails closed on inconsistent return data', () => {
const data = new Uint8Array(64);
new DataView(data.buffer).setBigUint64(24, 1n, true);
expect(() => S.decodeCpiResponse(data, 'bid')).toThrow();
expect(() => S.decodeCpiResponse(new Uint8Array(20), 'bid')).toThrow();
const missing = new Uint8Array(64);
new DataView(missing.buffer).setBigUint64(16, 600n, true);
expect(() => S.decodeCpiResponse(missing, 'bid')).toThrow();
});
it('classifies paid no-op cancels and explicit rejection logs', () => {
const p = S.parsePhoenixLogs(['Program log: cancel requested for cold trader; operation is a no-op']);
expect(p).toHaveProperty('coldNoop', true);
const r = S.parsePhoenixLogs(['Program log: PostOnly order would cross the book']);
expect(JSON.stringify(r)).toContain('PostOnly');
});
it('retains durable journal records without embedding identities in fixtures', () => {
const dir = mkdtempSync(join(tmpdir(), 'phx-kit-journal-'));
try {
const j = S.openJournal(join(dir, 'journal.jsonl'));
j.append({ v: 1, t: 'used', intentId: 'example-intent-1', ts: 1 });
expect(j.load()).toHaveLength(1);
j.close();
const read = S.openJournal(join(dir, 'journal.jsonl'));
expect(read.load()[0].intentId).toBe('example-intent-1');
read.close();
} finally {
unlinkSync(join(dir, 'journal.jsonl'));
rmdirSync(dir);
}
});
it('refuses corrupt or unreadable journals rather than forgetting spent intents', () => {
const dir = mkdtempSync(join(tmpdir(), 'phx-kit-journal-'));
try {
const file = join(dir, 'journal.jsonl');
const j = S.openJournal(file);
writeFileSync(file, '{broken}\n');
expect(() => j.load()).toThrow('corrupt');
j.close();
const broken = S.openJournal(file, {
fsImpl: {
mkdirSync: () => {},
openSync: () => 1,
readFileSync: () => {
throw new Error('unreadable');
},
closeSync: () => {},
},
});
expect(() => broken.load()).toThrow('unreadable');
broken.close();
} finally {
unlinkSync(join(dir, 'journal.jsonl'));
rmdirSync(dir);
}
});
it('refuses recovery from structurally invalid journal records', async () => {
const journal = { load: () => [{}], append: () => {}, rewrite: () => {}, close: () => {} };
const sc = S.createSidecar({ config: S.parseConfig(env()), journal, rpc: {}, api: {}, log: () => {} });
await expect(sc.start()).rejects.toThrow('invalid record');
await sc.stop({ graceMs: 0 });
});
it('reads synthesized on-chain metadata without writes in readonly mode', async () => {
const c = S.parseConfig(env());
let sends = 0;
const rpc = {
getAccountInfo: async (a: string) => ({
context: { slot: 100 },
value: {
owner: S.PROGRAM_ADDRESS,
lamports: 1,
data: [
Buffer.from(a === S.GLOBAL_CONFIGURATION ? globalBytes() : headerBytes()).toString('base64'),
'base64',
],
},
}),
getBalance: async () => ({ context: { slot: 100 }, value: 50000 }),
sendTransaction: async () => {
sends++;
throw new Error('No writes allowed');
},
};
const sc = S.createSidecar({
config: c,
rpc,
api: { getExchange: async () => ({ keys: exchangeKeys, markets: [market()] }) },
log: () => {},
});
try {
await sc.start();
const h = await sc.health();
expect(h.readonly).toBe(true);
expect(h.bindingOk).toBe(true);
await expect(
sc.submit('order', {
intentId: 'example-intent-2',
symbol: 'BTC',
kind: 'limit',
side: 'bid',
priceInTicks: '800',
numBaseLots: '1',
reduceOnly: false,
}),
).rejects.toMatchObject({ status: 403 });
expect(sends).toBe(0);
} finally {
await sc.stop({ graceMs: 0 });
}
});
it('signs once per intent, journals before send, and refuses a changed fingerprint', async () => {
const kitPath = '@solana/' + 'kit';
const solana = await import(kitPath);
const kp = await solana.generateKeyPairSigner();
let signs = 0;
const signer = {
...kp,
signTransactions: async (txs: unknown, config: unknown) => {
signs++;
return kp.signTransactions(txs, config);
},
};
const cfg = S.parseConfig({
...env(),
PHX_SIGNER_BOT_PUBKEY: String(kp.address),
PHX_SIGNER_READONLY: 'false',
PHX_SIGNER_JOURNAL: 'test-journal',
PHX_SIGNER_HTTP_WAIT_MS: '1000',
});
const hdr = headerBytes();
hdr.set(base58Decode(String(kp.address))!, 120);
const events: string[] = [];
const wires: string[] = [];
const recs: unknown[] = [];
const journal = {
load: () => [],
append: (r: unknown) => {
events.push('journal');
recs.push(r);
},
rewrite: () => {},
close: () => {},
};
const cpi = new Uint8Array(64);
const dv = new DataView(cpi.buffer);
dv.setBigUint64(16, 500n, true);
dv.setBigUint64(40, 1n, true);
const rpc = {
getAccountInfo: async (a: string) => ({
context: { slot: 100 },
value: {
owner: S.PROGRAM_ADDRESS,
lamports: 1,
data: [Buffer.from(a === S.GLOBAL_CONFIGURATION ? globalBytes() : hdr).toString('base64'), 'base64'],
},
}),
getBalance: async () => ({ context: { slot: 100 }, value: 50000 }),
getSlot: async () => 100,
getLatestBlockhash: async () => ({ blockhash: base58Encode(randomBytes(32)), lastValidBlockHeight: 200 }),
getRecentPrioritizationFees: async () => [],
getBlockHeight: async () => 100,
sendTransaction: async (wire: string) => {
events.push('send');
wires.push(wire);
return base58Encode(Buffer.from(wire, 'base64').subarray(1, 65));
},
getSignatureStatus: async () => ({ slot: 101, confirmationStatus: 'confirmed', err: null }),
getTransaction: async () => ({
slot: 101,
meta: {
err: null,
logMessages: [],
innerInstructions: [],
returnData: { programId: S.PROGRAM_ADDRESS, data: [Buffer.from(cpi).toString('base64'), 'base64'] },
},
}),
};
const sc = S.createSidecar({
config: cfg,
signer,
rpc,
journal,
api: { getExchange: async () => ({ keys: exchangeKeys, markets: [market()] }) },
log: () => {},
});
try {
await sc.start();
const body = {
intentId: 'example-unique-intent',
symbol: 'BTC',
kind: 'ioc',
side: 'bid',
priceInTicks: '800',
numBaseLots: '1',
reduceOnly: false,
};
const a = await sc.submit('order', body);
const b = await sc.submit('order', body);
expect(a.signature).toBe(b.signature);
expect(a.status).toBe('confirmed');
expect(signs).toBe(1);
expect(wires).toHaveLength(1);
expect(events.indexOf('journal')).toBeLessThan(events.indexOf('send'));
await expect(sc.submit('order', { ...body, numBaseLots: '2' })).rejects.toMatchObject({ status: 409 });
expect(recs.length).toBeGreaterThan(0);
} finally {
await sc.stop({ graceMs: 0 });
}
});
});