diff --git a/package.json b/package.json index 7a8f495..01d186c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "bash scripts/run-tests.sh", "start": "node ./app/index.js", "run": "node ./app/index.js", "eslint": "eslint . --ext ts --fix" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..a4706c5 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Runs every script under test/ as a plain Node process and reports a PASS/FAIL +# summary. The tests are top-level `assert` scripts (no test framework), so the +# contract is simply: exit 0 means pass. +# +# Most tests require("../app/...") — the compiled output — so the tree is built +# first. Set SKIP_BUILD=1 to reuse an existing app/ build. +# +set -u + +cd "$(dirname "$0")/.." + +if [ "${SKIP_BUILD:-0}" != "1" ]; then + echo "==> npm run build" + if ! npm run build; then + echo "==> build failed, not running tests" >&2 + exit 1 + fi + echo +fi + +shopt -s nullglob +tests=(test/*.js) +shopt -u nullglob + +if [ ${#tests[@]} -eq 0 ]; then + echo "==> no test files found under test/" >&2 + exit 1 +fi + +log_dir="$(mktemp -d)" +trap 'rm -rf "$log_dir"' EXIT + +passed=0 +failed=0 +failed_names=() + +echo "==> running ${#tests[@]} test(s)" +for test_file in "${tests[@]}"; do + log_file="$log_dir/$(basename "$test_file").log" + if node "$test_file" >"$log_file" 2>&1; then + echo "PASS $test_file" + passed=$((passed + 1)) + else + echo "FAIL $test_file" + failed=$((failed + 1)) + failed_names+=("$test_file") + # Only failures print their output, so a green run stays readable. + sed 's/^/ | /' "$log_file" + fi +done + +echo +echo "==> ${passed} passed, ${failed} failed, ${#tests[@]} total" + +if [ "$failed" -ne 0 ]; then + for name in "${failed_names[@]}"; do + echo "==> failed: $name" >&2 + done + exit 1 +fi diff --git a/src/chain/sol/sync.ts b/src/chain/sol/sync.ts index a0dc756..095998c 100644 --- a/src/chain/sol/sync.ts +++ b/src/chain/sol/sync.ts @@ -12,6 +12,7 @@ const SIGNATURE_PAGE_LIMIT = 100; const SOLANA_COMMITMENT = "finalized"; const SOLANA_RETRY_ATTEMPTS = 3; const SOLANA_RETRY_DELAY_MS = 1000; +const SOLANA_GET_TRANSACTION_RETRY_DELAYS_MS = [10000, 30000, 60000]; export class SolChain { cfg: Chain; @@ -22,6 +23,8 @@ export class SolChain { mcsAddresses: string[]; retryAttempts: number; retryDelayMs: number; + getTransactionRetryDelaysMs: number[]; + sleep: (ms: number) => Promise; constructor(cfg: Chain, butter: string, butterApiKey: string) { this.cfg = cfg @@ -32,6 +35,8 @@ export class SolChain { )) this.retryAttempts = SOLANA_RETRY_ATTEMPTS this.retryDelayMs = SOLANA_RETRY_DELAY_MS + this.getTransactionRetryDelaysMs = SOLANA_GET_TRANSACTION_RETRY_DELAYS_MS + this.sleep = delay this.parser = new SolEventParser({ eventProgramIds: this.mcsAddresses }) this.handler = new SolEventHandler(cfg, butter, butterApiKey) } @@ -51,7 +56,6 @@ export class SolChain { console.log("solana catch err", err) await delay(3000) } finally { - console.log("solana filter is running") await delay(3000) } } @@ -136,7 +140,7 @@ export class SolChain { throw new Error("solana getTransaction returned null") } return trx - }, () => true) + }, () => true, this.getTransactionRetryDelaysMs) } private async handleTransactionWithRetry( @@ -155,18 +159,21 @@ export class SolChain { txHash: string, operation: () => Promise, shouldRetry: (error: Error) => boolean, + retryDelaysMs?: number[], ): Promise { let lastError: Error | undefined - for (let attempt = 1; attempt <= this.retryAttempts; attempt++) { + const maxAttempts = retryDelaysMs ? retryDelaysMs.length + 1 : this.retryAttempts + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await operation() } catch (err) { lastError = toError(err) - if (attempt >= this.retryAttempts || !shouldRetry(lastError)) { + if (attempt >= maxAttempts || !shouldRetry(lastError)) { break } console.log("solana retry", stage, "txHash", txHash, "attempt", attempt, "err", lastError.message) - await delay(this.retryDelayMs) + await this.sleep(retryDelaysMs ? retryDelaysMs[attempt - 1] : this.retryDelayMs) } } throw lastError || new Error(`${stage} failed`) diff --git a/src/utils/time.ts b/src/utils/time.ts index 38a9cf4..ba6cfd6 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,3 +1,3 @@ -export function delay(ms: number) { +export function delay(ms: number): Promise { return new Promise( resolve => setTimeout(resolve, ms) ); -} \ No newline at end of file +} diff --git a/test/sol_handler_saved_fields_test.js b/test/sol_handler_saved_fields_test.js index bddfc89..3517872 100644 --- a/test/sol_handler_saved_fields_test.js +++ b/test/sol_handler_saved_fields_test.js @@ -1,28 +1,359 @@ const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const handler = fs.readFileSync( - path.join(__dirname, "../src/chain/sol/handler.ts"), - "utf8", -); - -[ - 'data.set("from", normalizeEventValue(event.data.user))', - 'data.set("fromChain", normalizeEventValue(event.data.from_chain))', - 'data.set("fromChainId", normalizeEventValue(event.data.from_chain))', - 'data.set("toChain", normalizeEventValue(event.data.to_chain))', - 'data.set("tokenAmount", normalizeEventValue(event.data.bridge_amount))', - 'data.set("minAmountOut", normalizeEventValue(event.data.min_amount_out))', - 'data.set("swapTokenOutMinAmountOut", normalizeEventValue(event.data.min_amount_out))', - 'data.set("amountOut", normalizeEventValue(event.data.source_amount))', - 'data.set("bridgeAmount", normalizeEventValue(event.data.bridge_amount))', - 'data.set("amount", normalizeEventValue(event.data.amount))', - 'data.set("amountOut", normalizeEventValue(event.data.amount_out))', - 'data.set("signedMinAmountOut", normalizeEventValue(event.data.signed_min_amount_out))', - 'data.set("effectiveMinAmountOut", normalizeEventValue(event.data.effective_min_amount_out))', -].forEach((expected) => { - assert(handler.includes(expected), `missing saved field shape: ${expected}`); -}); -assert(!handler.includes('data.set("sourceAmount"')); +const { BN } = require("@project-serum/anchor"); +const { PublicKey } = require("@solana/web3.js"); +const { ethers } = require("ethers"); + +const { SolEventHandler } = require("../app/chain/sol/handler"); +const mysqlStorage = require("../app/storages/mysql/mysql"); +const butterClient = require("../app/utils/butter/butter"); +const slackAlarm = require("../app/utils/alarm/slack"); + +// Solana mainnet USDC — also the first entry of the handler's default supported bridge mints. +const BRIDGE_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); +const UNSUPPORTED_MINT = new PublicKey("mosv35NjuYkM8iMdihM96cG2ZziU2RJYZfSaGuV8uF6"); +const USER = new PublicKey("mosv35NjuYkM8iMdihM96cG2ZziU2RJYZfSaGuV8uF6"); +const DEST_TOKEN = new PublicKey("So11111111111111111111111111111111111111112"); +const EVM_TOKEN = "0x35D70661F9e831730014E3b4F4b4CF7cE38e0634"; +const EVM_RECEIVER = "0x1111111111111111111111111111111111111111"; +const MINT_DECIMALS = 6; + +const CHAIN = { + name: "solana", + type: "solana", + id: "1360108768460811", + endpoint: "https://api.mainnet-beta.solana.com", + opts: { + startBlock: "0", + mcs: "mosv35NjuYkM8iMdihM96cG2ZziU2RJYZfSaGuV8uF6", + blockConfirmations: "1", + butterEntrance: "buttertest", + }, +}; + +const TRX = { slot: 987654, blockTime: 1750000000 }; + +function bytes32WithAddressAtEnd(address) { + const out = new Uint8Array(32); + out.set(ethers.getBytes(address), 12); + return Array.from(out); +} + +function bytes33WithAddressAtEnd(address) { + // crossOut reads the receiver from bytes 13..32 of a 33 byte buffer. + const out = new Uint8Array(33); + out.set(ethers.getBytes(address), 13); + return Array.from(out); +} + +function mintAccountData(decimals) { + const data = Buffer.alloc(82); + data.writeUInt8(decimals, 44); + return data; +} + +function fakeConnection() { + return { + async getAccountInfo() { + return { data: mintAccountData(MINT_DECIMALS) }; + }, + }; +} + +// The compiled handler calls insertMos / requestBridgeData / alarm through the module +// namespace object, so replacing the exports is enough to observe them. +async function withStubs(stubs, body) { + const inserted = []; + const alarms = []; + const butterCalls = []; + + const original = { + insertMos: mysqlStorage.insertMos, + requestBridgeData: butterClient.requestBridgeData, + alarm: slackAlarm.alarm, + }; + + mysqlStorage.insertMos = async (log, callback) => { + inserted.push(log); + callback(null, inserted.length); + return 1; + }; + butterClient.requestBridgeData = async (...args) => { + butterCalls.push(args); + return stubs.bridgeData || { relay: false, data: "0x", receiver: EVM_RECEIVER }; + }; + slackAlarm.alarm = async (message) => { + alarms.push(message); + }; + + try { + return await body({ inserted, alarms, butterCalls }); + } finally { + mysqlStorage.insertMos = original.insertMos; + butterClient.requestBridgeData = original.requestBridgeData; + slackAlarm.alarm = original.alarm; + } +} + +function newHandler() { + return new SolEventHandler(CHAIN, "https://bridge.example/api", "test-api-key"); +} + +function savedLog(inserted) { + assert.strictEqual(inserted.length, 1, "expected exactly one mos row to be inserted"); + return inserted[0]; +} + +function savedFields(inserted) { + return JSON.parse(savedLog(inserted).LogData); +} + +function crossOutEvent(overrides = {}) { + return { + name: "CrossOutEvent", + data: { + order_id: Array.from({ length: 32 }, (_, i) => i + 1), + user: USER, + from_chain: new BN("1360108768460811"), + to_chain: new BN("137"), + bridge_mint: BRIDGE_MINT, + bridge_amount: new BN("20000000"), + to_token: bytes32WithAddressAtEnd(EVM_TOKEN), + receiver: bytes33WithAddressAtEnd(EVM_RECEIVER), + min_amount_out: new BN("6112416118188836"), + source_token: BRIDGE_MINT, + source_amount: new BN("19900000"), + referer_id: [9], + fee_ratio: [100], + ...overrides, + }, + }; +} + +async function testCrossOutSavedFields() { + await withStubs({}, async ({ inserted, butterCalls }) => { + const event = crossOutEvent(); + await newHandler().crossOut(event, "cross-out-tx", CHAIN.opts.mcs, fakeConnection(), TRX); + + const log = savedLog(inserted); + assert.strictEqual(log.ChainId, CHAIN.id); + assert.strictEqual(log.EventId, 98); + assert.strictEqual(log.ProjectId, 7); + assert.strictEqual(log.Topic, "crossOut"); + assert.strictEqual(log.TxHash, "cross-out-tx"); + assert.strictEqual(log.ContractAddres, CHAIN.opts.mcs); + assert.strictEqual(log.BlockNumber, TRX.slot); + assert.strictEqual(log.TxTimestamp, TRX.blockTime); + + const fields = JSON.parse(log.LogData); + + assert.strictEqual( + fields.orderId, + Buffer.from(Uint8Array.from(event.data.order_id)).toString("hex"), + ); + + // `user` keeps the base58 form; `from` is the raw 32 byte public key. + assert.strictEqual(fields.user, USER.toBase58()); + assert.deepStrictEqual(fields.from, Array.from(USER.toBytes())); + assert.strictEqual(fields.from.length, 32); + + // Chain ids and amounts are stored as even-length, unprefixed hex. + assert.strictEqual(fields.fromChain, "04d5030000000b"); + assert.strictEqual(fields.fromChainId, "04d5030000000b"); + assert.strictEqual(fields.toChain, "89"); + assert.strictEqual(fields.tokenAmount, "012fa660"); // hex(19900000), from source_amount + assert.strictEqual(fields.amountOut, "012fa660"); // hex(19900000), from source_amount + assert.strictEqual(fields.minAmountOut, "15b735c771ab24"); + assert.strictEqual(fields.swapTokenOutMinAmountOut, "15b735c771ab24"); + + // bridgeAmount stays a decimal string here (normalizeEventValue, not the hex helper). + assert.strictEqual(fields.bridgeAmount, "20000000"); + + assert.strictEqual(fields.bridgeMint, BRIDGE_MINT.toBase58()); + assert.strictEqual(fields.fromToken, BRIDGE_MINT.toBase58()); + assert.deepStrictEqual(fields.toToken, event.data.to_token); + assert.deepStrictEqual(fields.originReceiver, event.data.receiver); + assert.deepStrictEqual(fields.refererId, [9]); + assert.deepStrictEqual(fields.feeRatio, [100]); + + // The bridge api response wins for relay / swapData / receiver. + assert.strictEqual(fields.relay, false); + assert.strictEqual(fields.swapData, "0x"); + assert.strictEqual(fields.receiver, EVM_RECEIVER); + + // Renamed away from `sourceAmount` — the field must not come back. + assert.strictEqual("sourceAmount" in fields, false); + + // The bridge api is asked with the checksummed EVM token and the decimal-adjusted amount. + assert.strictEqual(butterCalls.length, 1); + const [butterUrl, butterTxHash, butterApiKey, butterQuery] = butterCalls[0]; + assert.strictEqual(butterUrl, "https://bridge.example/api"); + assert.strictEqual(butterTxHash, "cross-out-tx"); + assert.strictEqual(butterApiKey, "test-api-key"); + assert.strictEqual(butterQuery.entrance, CHAIN.opts.butterEntrance); + assert.strictEqual(butterQuery.tokenInAddress, BRIDGE_MINT.toBase58()); + assert.strictEqual(butterQuery.tokenOutAddress, ethers.getAddress(EVM_TOKEN)); + assert.strictEqual(butterQuery.amount, ethers.formatUnits(20000000n, MINT_DECIMALS)); + assert.strictEqual(butterQuery.receiver, EVM_RECEIVER); + assert.strictEqual(butterQuery.caller, USER.toBase58()); + assert.strictEqual(butterQuery.entranceId, "9"); + assert.deepStrictEqual(butterQuery.affiliate, ["9:100"]); + }); +} + +async function testCrossOutNativeTokenOutFallsBackToConfiguredAddress() { + await withStubs({}, async ({ butterCalls }) => { + const event = crossOutEvent({ to_token: Array.from({ length: 32 }, () => 0) }); + await newHandler().crossOut(event, "native-out-tx", CHAIN.opts.mcs, fakeConnection(), TRX); + + assert.strictEqual( + butterCalls[0][3].tokenOutAddress, + "0x0000000000000000000000000000000000000000", + ); + }); +} + +async function testCrossOutUnsupportedBridgeMintAlarmsAndSkipsInsert() { + await withStubs({}, async ({ inserted, alarms }) => { + const event = crossOutEvent({ bridge_mint: UNSUPPORTED_MINT }); + await newHandler().crossOut(event, "unsupported-tx", CHAIN.opts.mcs, fakeConnection(), TRX); + + assert.strictEqual(inserted.length, 0); + assert.strictEqual(alarms.length, 1); + assert(alarms[0].includes("unsupported CrossOut bridgeMint")); + assert(alarms[0].includes(UNSUPPORTED_MINT.toBase58())); + }); +} + +async function testCrossOutIgnoresOtherEvents() { + await withStubs({}, async ({ inserted }) => { + const event = crossOutEvent(); + event.name = "CrossInEvent"; + await newHandler().crossOut(event, "wrong-event-tx", CHAIN.opts.mcs, fakeConnection(), TRX); + + assert.strictEqual(inserted.length, 0); + }); +} + +async function testCrossInSavedFields() { + await withStubs({}, async ({ inserted }) => { + const event = { + name: "CrossInEvent", + data: { + order_id: Array.from({ length: 32 }, (_, i) => i), + user: USER, + from_chain: new BN("137"), + bridge_mint: BRIDGE_MINT, + bridge_amount: new BN("1500000"), + dest_token: DEST_TOKEN, + amount: new BN("1400000"), + amount_out: new BN("1350000"), + signed_min_amount_out: new BN("1300000"), + effective_min_amount_out: new BN("1250000"), + }, + }; + + await newHandler().crossIn(event, "cross-in-tx", CHAIN.opts.mcs, TRX); + + const log = savedLog(inserted); + assert.strictEqual(log.EventId, 124); + assert.strictEqual(log.Topic, "crossIn"); + + const fields = JSON.parse(log.LogData); + assert.strictEqual(fields.user, USER.toBase58()); + assert.deepStrictEqual(fields.from, Array.from(USER.toBytes())); + assert.deepStrictEqual(fields.fromToken, Array.from({ length: 32 }, () => 0)); + assert.strictEqual(fields.fromChainId, "89"); + assert.strictEqual(fields.bridgeMint, BRIDGE_MINT.toBase58()); + assert.strictEqual(fields.bridgeAmount, "16e360"); // hex(1500000) + assert.strictEqual(fields.tokenAmount, "16e360"); + assert.strictEqual(fields.destToken, DEST_TOKEN.toBase58()); + assert.deepStrictEqual(fields.toToken, Array.from(DEST_TOKEN.toBytes())); + assert.strictEqual(fields.amount, "155cc0"); // hex(1400000) + assert.strictEqual(fields.amountOut, "149970"); // hex(1350000) + assert.strictEqual(fields.signedMinAmountOut, "13d620"); // hex(1300000) + assert.strictEqual(fields.minAmountOut, "13d620"); + assert.strictEqual(fields.swapTokenOutMinAmountOut, "13d620"); + assert.strictEqual(fields.effectiveMinAmountOut, "1312d0"); // hex(1250000) + assert.strictEqual(fields.refererId, null); + assert.strictEqual(fields.feeRatio, null); + assert.strictEqual(fields.receiver, USER.toBase58()); + assert.strictEqual("isrefund" in fields, false); + }); +} + +async function testCrossInDefaultsWhenOptionalFieldsAreAbsent() { + await withStubs({}, async ({ inserted }) => { + const event = { + name: "CrossInEvent", + data: { + order_id: Array.from({ length: 32 }, () => 0), + user: USER, + bridge_mint: BRIDGE_MINT, + }, + }; + + await newHandler().crossIn(event, "cross-in-minimal-tx", CHAIN.opts.mcs, TRX); + + const fields = savedFields(inserted); + assert.strictEqual(fields.fromChainId, "00"); + assert.strictEqual(fields.tokenAmount, "00"); + assert.strictEqual(fields.minAmountOut, "00"); + assert.strictEqual(fields.swapTokenOutMinAmountOut, "00"); + // No dest_token: the bridge mint bytes are used as the destination token. + assert.deepStrictEqual(fields.toToken, Array.from(BRIDGE_MINT.toBytes())); + assert.strictEqual("bridgeAmount" in fields, false); + assert.strictEqual("amount" in fields, false); + assert.strictEqual("amountOut" in fields, false); + assert.strictEqual("effectiveMinAmountOut" in fields, false); + }); +} + +async function testRefundEventIsFlagged() { + await withStubs({}, async ({ inserted }) => { + const event = { + name: "RefundEvent", + data: { + order_id: Array.from({ length: 32 }, () => 7), + user: USER, + bridge_mint: BRIDGE_MINT, + bridge_amount: new BN("42"), + }, + }; + + await newHandler().crossIn(event, "refund-tx", CHAIN.opts.mcs, TRX); + + const fields = savedFields(inserted); + assert.strictEqual(fields.isrefund, true); + assert.strictEqual(fields.bridgeAmount, "2a"); // hex(42) + }); +} + +async function testCrossInIgnoresOtherEvents() { + await withStubs({}, async ({ inserted }) => { + const event = { + name: "CrossOutEvent", + data: { order_id: [], user: USER, bridge_mint: BRIDGE_MINT }, + }; + + await newHandler().crossIn(event, "wrong-event-tx", CHAIN.opts.mcs, TRX); + + assert.strictEqual(inserted.length, 0); + }); +} + +async function run() { + await testCrossOutSavedFields(); + await testCrossOutNativeTokenOutFallsBackToConfiguredAddress(); + await testCrossOutUnsupportedBridgeMintAlarmsAndSkipsInsert(); + await testCrossOutIgnoresOtherEvents(); + await testCrossInSavedFields(); + await testCrossInDefaultsWhenOptionalFieldsAreAbsent(); + await testRefundEventIsFlagged(); + await testCrossInIgnoresOtherEvents(); +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/sol_sync_retry_test.js b/test/sol_sync_retry_test.js index 9994ae9..4d13056 100644 --- a/test/sol_sync_retry_test.js +++ b/test/sol_sync_retry_test.js @@ -5,7 +5,7 @@ const { SolChain } = require("../app/chain/sol/sync"); const mcsAddress = "11111111111111111111111111111111"; function createChain() { - return new SolChain( + const chain = new SolChain( { name: "solana", type: "sol", @@ -21,11 +21,12 @@ function createChain() { "http://butter", "api-key", ); + chain.sleep = async () => {}; + return chain; } async function testRetriesRpcGetTransactionErrors() { const chain = createChain(); - chain.retryDelayMs = 0; let attempts = 0; const tx = { meta: { err: null } }; const connection = { @@ -44,9 +45,35 @@ async function testRetriesRpcGetTransactionErrors() { assert.strictEqual(attempts, 3); } +async function testUsesLongerRetryDelaySequence() { + const chain = createChain(); + chain.getTransactionRetryDelaysMs = [10000, 30000, 60000]; + const delays = []; + chain.sleep = async (ms) => { + delays.push(ms); + }; + + let attempts = 0; + const tx = { meta: { err: null } }; + const connection = { + getTransaction: async () => { + attempts += 1; + if (attempts <= 3) { + throw new Error("solana getTransaction returned null"); + } + return tx; + }, + }; + + const result = await chain.getTransactionWithRetry(connection, "tx-long-retry-delay"); + + assert.strictEqual(result, tx); + assert.strictEqual(attempts, 4); + assert.deepStrictEqual(delays, [10000, 30000, 60000]); +} + async function testFailedChainTransactionAdvancesCursor() { const chain = createChain(); - chain.retryDelayMs = 0; chain.getSignaturesSince = async () => [{ signature: "failed-chain-tx" }]; chain.getTransactionWithRetry = async () => ({ meta: { err: { InstructionError: [10, "ComputationalBudgetExceeded"] } }, @@ -62,7 +89,6 @@ async function testFailedChainTransactionAdvancesCursor() { async function testHandleErrorsDoNotAdvanceCursorAfterRetries() { const chain = createChain(); - chain.retryDelayMs = 0; chain.getSignaturesSince = async () => [{ signature: "successful-chain-tx" }]; chain.getTransactionWithRetry = async () => ({ meta: { err: null } }); @@ -85,7 +111,6 @@ async function testHandleErrorsDoNotAdvanceCursorAfterRetries() { async function testMalformedButterResponseErrorsAreRetried() { const chain = createChain(); - chain.retryDelayMs = 0; chain.getSignaturesSince = async () => [{ signature: "malformed-butter-response-tx" }]; chain.getTransactionWithRetry = async () => ({ meta: { err: null } }); @@ -126,6 +151,7 @@ async function testSignaturePageLimitIs100() { async function run() { await testRetriesRpcGetTransactionErrors(); + await testUsesLongerRetryDelaySequence(); await testFailedChainTransactionAdvancesCursor(); await testHandleErrorsDoNotAdvanceCursorAfterRetries(); await testMalformedButterResponseErrorsAreRetried();