Skip to content
Merged
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
39 changes: 39 additions & 0 deletions .github/workflows/vault-diagnostics.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Vault Live Diagnostics

on:
pull_request:
branches: ["main"]

permissions:
contents: read

jobs:
diagnose:
name: Measure Vault mainnet reads
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install locked dependencies
run: npm ci --ignore-scripts

- name: Measure live Hiro API and Vault scan cost
run: node scripts/diagnose-vault.mjs | tee vault-diagnostic-report.log

- name: Upload diagnostic report
if: always()
uses: actions/upload-artifact@v4
with:
name: vault-diagnostic-report
path: vault-diagnostic-report.log
if-no-files-found: error
retention-days: 3
154 changes: 154 additions & 0 deletions scripts/diagnose-vault.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
cvToHex,
hexToCV,
standardPrincipalCV,
tupleCV,
uintCV,
} from '@stacks/transactions';

const API = 'https://api.mainnet.hiro.so';
const DEPLOYER = 'SP3GHKMV4GSYNA8WGBX83DACG80K1RRVQZAZMB9J3';
const CONTRACT = 'staking-refinery-v7';
const TEST_ADDRESS = DEPLOYER;
const SAMPLE_LIMIT = 20;
const TIMEOUT_MS = 15_000;

async function timedFetch(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
const startedAt = performance.now();

try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
const elapsedMs = performance.now() - startedAt;
return { response, elapsedMs };
} finally {
clearTimeout(timeout);
}
}

function readUint(cv) {
const value = cv?.value?.value ?? cv?.value ?? 0;
const number = Number(value);
if (!Number.isSafeInteger(number) || number < 0) {
throw new Error(`Invalid uint value: ${String(value)}`);
}
return number;
}

function percentile(values, ratio) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.floor(sorted.length * ratio));
return sorted[index];
}

function makeSampleIds(maxId, count) {
if (maxId <= 0) return [];
if (maxId <= count) return Array.from({ length: maxId }, (_, id) => id);

const ids = new Set([0, maxId - 1]);
for (let index = 1; ids.size < count; index += 1) {
ids.add(Math.floor((index * (maxId - 1)) / (count - 1)));
}
return [...ids].sort((a, b) => a - b).slice(0, count);
}

async function fetchStake(id) {
const key = tupleCV({
user: standardPrincipalCV(TEST_ADDRESS),
id: uintCV(id),
});

const { response, elapsedMs } = await timedFetch(
`${API}/v2/map_entry/${DEPLOYER}/${CONTRACT}/stakes`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cvToHex(key)),
},
);

const body = await response.text();
if (!response.ok) {
throw new Error(`Stake ${id} returned ${response.status}: ${body.slice(0, 180)}`);
}

return { id, elapsedMs, bodyBytes: Buffer.byteLength(body) };
}

async function main() {
const infoResult = await timedFetch(`${API}/v2/info`);
const info = await infoResult.response.json();
if (!infoResult.response.ok) {
throw new Error(`/v2/info returned ${infoResult.response.status}`);
}

const nonceResult = await timedFetch(
`${API}/v2/data_var/${DEPLOYER}/${CONTRACT}/nonce`,
);
const noncePayload = await nonceResult.response.json();
if (!nonceResult.response.ok || !noncePayload.data) {
throw new Error(`nonce read returned ${nonceResult.response.status}`);
}

const maxId = readUint(hexToCV(noncePayload.data));
const sampleIds = makeSampleIds(maxId, SAMPLE_LIMIT);

const serialStartedAt = performance.now();
const serialResults = [];
for (const id of sampleIds) {
serialResults.push(await fetchStake(id));
}
const serialTotalMs = performance.now() - serialStartedAt;

const concurrentStartedAt = performance.now();
await Promise.all(sampleIds.map((id) => fetchStake(id)));
const concurrentTotalMs = performance.now() - concurrentStartedAt;

const requestTimes = serialResults.map((item) => item.elapsedMs);
const averageRequestMs = requestTimes.length
? requestTimes.reduce((sum, value) => sum + value, 0) / requestTimes.length
: 0;

const projectedFullSerialMs = averageRequestMs * maxId;

const report = {
testedAt: new Date().toISOString(),
api: API,
burnBlockHeight: info.burn_block_height,
stacksTipHeight: info.stacks_tip_height,
infoLatencyMs: Math.round(infoResult.elapsedMs),
nonceLatencyMs: Math.round(nonceResult.elapsedMs),
globalStakeNonce: maxId,
sampledMapEntries: sampleIds.length,
serialSampleTotalMs: Math.round(serialTotalMs),
concurrentSampleTotalMs: Math.round(concurrentTotalMs),
averageMapEntryMs: Math.round(averageRequestMs),
p50MapEntryMs: Math.round(percentile(requestTimes, 0.5)),
p95MapEntryMs: Math.round(percentile(requestTimes, 0.95)),
projectedCurrentVaultSerialMs: Math.round(projectedFullSerialMs),
projectedCurrentVaultSerialSeconds: Number((projectedFullSerialMs / 1000).toFixed(1)),
speedupForSample: concurrentTotalMs > 0
? Number((serialTotalMs / concurrentTotalMs).toFixed(2))
: null,
};

console.log('VAULT_DIAGNOSTIC_REPORT');
console.log(JSON.stringify(report, null, 2));

if (maxId > 100) {
console.log(
`Finding confirmed: Vault scans ${maxId} global stake IDs serially before clearing its shared loading state.`,
);
}
}

main().catch((error) => {
console.error('VAULT_DIAGNOSTIC_FAILED');
console.error(error);
process.exitCode = 1;
});
Loading