Merge pull request #14 from FilipBl4gojevic/feat/hardening-batch
Hardening batch: dependency advisories, exact amount math, dust floor, endpoint-safe error text, node floor
This commit is contained in:
41
README.md
41
README.md
@@ -170,11 +170,27 @@ Example non-custodial flow for a registry op: `vector_build_register_agent` →
|
||||
|
||||
### 1. Install and build
|
||||
|
||||
Requires Node.js >= 22 (matches `engines` in every `package.json` and the Dockerfile's
|
||||
`node:22-alpine` base).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Dependency advisories.** `npm audit` currently reports 1 low-severity advisory: an `esbuild`
|
||||
arbitrary-file-read issue that only triggers via its own Windows dev server, reachable solely
|
||||
through `tsup` (a build-time-only dependency, absent from the production image, which installs
|
||||
with `npm ci --omit=dev`). As of 2026-07-31 this tree carried 44 advisories, including 1
|
||||
critical (`tar`, GHSA-23hp-3jrh-7fpw) that arrived because `@cardano-sdk/crypto` - pulled in
|
||||
transitively through `@lucid-evolution/lucid` - declared the entire `npm` CLI package as a
|
||||
runtime dependency. That dependency was dead weight: `@cardano-sdk/crypto`'s own published code
|
||||
never requires `npm`, and neither `npm` nor `tar` appears anywhere in this server's built
|
||||
bundle. Regenerating the lockfile picked up a newer `@cardano-sdk/crypto` release that dropped
|
||||
the stray dependency outright, clearing all 43 of those advisories without any override needed;
|
||||
`package.json` also carries a defensive `overrides` pin on `tar` and `brace-expansion` against
|
||||
future regressions, though it is not what resolved this one.
|
||||
|
||||
### 2. Configure environment (optional)
|
||||
|
||||
```bash
|
||||
@@ -283,6 +299,31 @@ it, lives in your local signer instead - see `VECTOR_SIGNER_SPEND_LIMIT_PER_TX`
|
||||
> echoes a token value. Commas delimit entries and cannot be escaped, so generate
|
||||
> tokens from a comma-free charset (hex / base64url / alphanumeric).
|
||||
|
||||
> **Error responses do not echo configured endpoints.** This covers every failure
|
||||
> mode a network call can produce, not just an HTTP-level rejection: a DNS
|
||||
> failure, a dropped connection, and a malformed (non-JSON) response body are all
|
||||
> sanitised the same way an ordinary `4xx`/`5xx` status is - the underlying HTTP
|
||||
> client is the platform's native `fetch`, chosen in part because its own errors
|
||||
> do not embed the request URL the way some polyfills do. A failed Ogmios or
|
||||
> Koios query is this server's own infrastructure problem, not something the
|
||||
> caller can act on - it is reported by service name and status only, for
|
||||
> example "Ogmios request failed (queryLedgerState/utxo): 503 Service
|
||||
> Unavailable" or, for a connection failure, "...: network error". A rejected
|
||||
> transaction submission is different: the ledger's verdict on the CALLER'S OWN
|
||||
> transaction (a bad input, an unmet script condition, a fee too small) stays in
|
||||
> the response, since it is the feedback loop an agent needs to self-correct after
|
||||
> build → sign → submit - only URL-shaped content is scrubbed out of it first, for
|
||||
> example "Transaction submission rejected (400 Bad Request):
|
||||
> ValueNotConservedUTxO...". A NETWORK failure to even reach the submit API is
|
||||
> reported as an infrastructure failure instead ("...: network error"), never
|
||||
> worded as a ledger rejection, since nothing looked at the transaction at all.
|
||||
> Neither case ever echoes the configured `VECTOR_OGMIOS_URL` / `VECTOR_KOIOS_URL`
|
||||
> / `VECTOR_SUBMIT_URL` value itself, and full unscrubbed detail always goes to
|
||||
> the server's own `console.error` log for operators. **Known limit:** the
|
||||
> submission-rejection scrub removes http(s) URLs and the three endpoints above
|
||||
> by name - a rejection body that happens to name some other internal hostname
|
||||
> passes through unscrubbed, bounded by a 2000-character cap.
|
||||
|
||||
### Mainnet endpoints
|
||||
|
||||
| Variable | Mainnet value |
|
||||
|
||||
5081
package-lock.json
generated
5081
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"workspaces": ["packages/*"],
|
||||
"scripts": {
|
||||
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs 2>/dev/null || true",
|
||||
"postinstall": "node scripts/link-libsodium.mjs",
|
||||
"clean": "rm -rf packages/*/build",
|
||||
"build": "npm run build --workspace=@apexfusion/vector-mcp-builder && npm run build --workspace=@apexfusion/vector-mcp-signer",
|
||||
"typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p packages/builder && tsc --noEmit -p packages/signer",
|
||||
@@ -24,8 +24,12 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"overrides": {
|
||||
"tar": "^7.5.21",
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
"license": "ISC",
|
||||
"author": "Apex Fusion",
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
"@lucid-evolution/lucid": "^0.4.29",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apexfusion/vector-mcp-shared": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ ${capList}
|
||||
**Troubleshooting Tips:**
|
||||
1. Verify the DID format: did:vector:agent:{policyId}:{nftAssetName}
|
||||
2. The agent may not be registered or may have deregistered
|
||||
3. Check that Koios is reachable at ${VECTOR_KOIOS_URL}`,
|
||||
3. Verify the configured Koios endpoint is reachable`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -133,7 +133,7 @@ ${capList}
|
||||
text: `Failed to discover agents: ${err instanceof Error ? err.message : String(err)}
|
||||
|
||||
**Troubleshooting Tips:**
|
||||
1. Check that Ogmios is reachable at ${VECTOR_OGMIOS_URL}
|
||||
1. Verify the configured Ogmios endpoint is reachable
|
||||
2. The registry may be empty - no agents have registered yet
|
||||
3. Try without filters to list all agents`,
|
||||
}],
|
||||
@@ -189,7 +189,7 @@ The DID above is bound to the exact wallet UTxO this build consumed - if that UT
|
||||
**Troubleshooting Tips:**
|
||||
1. Ensure the changeAddress wallet holds at least 12 AP3X (10 deposit + fees)
|
||||
2. Each wallet can register multiple agents (different one-shot UTxOs)
|
||||
3. Verify Ogmios endpoint is reachable at ${VECTOR_OGMIOS_URL}`,
|
||||
3. Verify the configured Ogmios endpoint is reachable`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
validatorToScriptHash, getAddressDetails,
|
||||
} from '@lucid-evolution/lucid';
|
||||
import type { LucidEvolution, Provider, SpendingValidator, TxSignBuilder } from '@lucid-evolution/lucid';
|
||||
import { lovelaceToAda } from '@apexfusion/vector-mcp-shared/tx';
|
||||
import { lovelaceToAda, apexToLovelace } from '@apexfusion/vector-mcp-shared/tx';
|
||||
import type {
|
||||
TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult,
|
||||
} from '@apexfusion/vector-mcp-shared/types';
|
||||
@@ -63,7 +63,7 @@ export async function buildSendApex(
|
||||
if (typeof p.amountApex !== 'number' || !Number.isFinite(p.amountApex) || p.amountApex <= 0) {
|
||||
throw new Error('Amount must be a positive number');
|
||||
}
|
||||
const lovelace = BigInt(Math.floor(p.amountApex * 1_000_000));
|
||||
const lovelace = apexToLovelace(p.amountApex);
|
||||
let tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { lovelace });
|
||||
if (p.metadataJson) {
|
||||
tx = tx.attachMetadata(674, JSON.parse(p.metadataJson));
|
||||
@@ -87,7 +87,7 @@ export async function buildSendTokens(
|
||||
assetNameHex = fromText(p.assetName);
|
||||
}
|
||||
const unit = `${p.policyId}${assetNameHex}`;
|
||||
const outputLovelace = p.apexAmount ? BigInt(Math.floor(p.apexAmount * 1_000_000)) : 2_000_000n;
|
||||
const outputLovelace = p.apexAmount ? apexToLovelace(p.apexAmount) : 2_000_000n;
|
||||
const tx = lucid.newTx().pay.ToAddress(p.recipientAddress, {
|
||||
lovelace: outputLovelace,
|
||||
[unit]: BigInt(p.amount),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
|
||||
import { blake2b } from '@noble/hashes/blake2b';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
||||
import { apexToLovelace } from '@apexfusion/vector-mcp-shared/tx';
|
||||
import type {
|
||||
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
|
||||
} from '@apexfusion/vector-mcp-shared/types';
|
||||
@@ -387,7 +388,7 @@ export async function buildCritique(lucid: LucidEvolution, p: {
|
||||
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
|
||||
throw new Error(`Critique stake must be at least ${MIN_CRITIQUE_STAKE_APEX} AP3X.`);
|
||||
}
|
||||
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
|
||||
const stakeLovelace = apexToLovelace(p.stakeApex);
|
||||
|
||||
let finalHash = p.critiqueHash;
|
||||
let finalUri = p.storageUri;
|
||||
@@ -473,7 +474,7 @@ export async function buildEndorse(lucid: LucidEvolution, p: {
|
||||
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
|
||||
throw new Error(`Endorsement stake must be at least ${MIN_ENDORSE_STAKE_APEX} AP3X.`);
|
||||
}
|
||||
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
|
||||
const stakeLovelace = apexToLovelace(p.stakeApex);
|
||||
|
||||
const vkeyHash = paymentKeyHashOf(changeAddress);
|
||||
|
||||
@@ -532,7 +533,7 @@ export async function buildProposalLock(lucid: LucidEvolution, p: {
|
||||
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
|
||||
throw new Error(`Proposal stake must be at least ${MIN_PROPOSAL_STAKE_APEX} AP3X.`);
|
||||
}
|
||||
const stakeLovelace = BigInt(Math.floor(p.stakeApex * 1_000_000));
|
||||
const stakeLovelace = apexToLovelace(p.stakeApex);
|
||||
|
||||
// Proposal-type datum. Same Constr indices as the custodial switch - the
|
||||
// deployed module validator parses these exact bytes.
|
||||
|
||||
@@ -197,7 +197,7 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
|
||||
|
||||
**Troubleshooting Tips:**
|
||||
1. Verify the module contracts are deployed on this network
|
||||
2. Check that Ogmios is reachable at ${VECTOR_OGMIOS_URL}
|
||||
2. Verify the configured Ogmios endpoint is reachable
|
||||
3. The script addresses may not have any UTxOs yet`,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -179,7 +179,7 @@ ${utxoList}`,
|
||||
{
|
||||
changeAddress: z.string().describe("Your wallet address (source of funds; receives change). Get it from your local signer's vector_signer_get_address."),
|
||||
recipientAddress: z.string().describe("Recipient Vector address (addr1...)"),
|
||||
amount: z.number().min(1).describe("Amount of AP3X to send"),
|
||||
amount: z.number().min(0.000001).describe("Amount of AP3X to send (minimum 0.000001 = 1 lovelace; the ledger's own minimum-UTxO rule applies per output and will reject dust with a clear error)"),
|
||||
metadata: z.string().optional().describe("Optional transaction metadata in JSON format"),
|
||||
},
|
||||
async ({ changeAddress, recipientAddress, amount, metadata }) => {
|
||||
@@ -590,7 +590,7 @@ ${txList}
|
||||
text: `Failed to get transaction history: ${error.message}
|
||||
|
||||
**Troubleshooting Tips:**
|
||||
1. Ensure Koios is configured and reachable: ${VECTOR_KOIOS_URL}
|
||||
1. Ensure the configured Koios endpoint is reachable
|
||||
2. Verify the address is valid
|
||||
3. Check the block explorer for this address`,
|
||||
}],
|
||||
|
||||
@@ -133,6 +133,52 @@ describe('buildSendApex', () => {
|
||||
await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 0 }), /positive/);
|
||||
await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: -5 }), /positive/);
|
||||
});
|
||||
|
||||
test('pins exact lovelace at the call site for a fractional amount float math gets wrong', async () => {
|
||||
// 1.005 * 1_000_000 === 1004999.9999999999 in IEEE-754 (node -e
|
||||
// "console.log(Math.floor(1.005*1e6))" -> 1004999, one lovelace short of
|
||||
// the correct 1005000; see packages/shared/test/tx.test.ts for the full
|
||||
// empirical proof). apexToLovelace itself is already proven exact there -
|
||||
// this test instead pins that buildSendApex's call site actually invokes
|
||||
// it, so a future edit reverting to Math.floor(amountApex * 1_000_000)
|
||||
// fails HERE, not just in an untouched helper's own unit tests.
|
||||
const r = await buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 1.005 });
|
||||
const outs = decodeOutputs(r.txCbor);
|
||||
const toRecipient = outs.filter((o) => o.address === FOREIGN_ADDRESS);
|
||||
assert.equal(toRecipient.length, 1);
|
||||
assert.equal(
|
||||
toRecipient[0].lovelace, 1_005_000n,
|
||||
'lost a lovelace - buildSendApex must call apexToLovelace, not Math.floor(amountApex * 1_000_000)',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds a sub-1-AP3X send at the new schema floor (audit D: the dust floor)', async () => {
|
||||
// vector.ts's amount schema used to be z.number().min(1), which forbade
|
||||
// this send outright. Task 3 moved the schema floor to 0.000001 AP3X (one
|
||||
// lovelace) and left the ledger's own min-UTxO rule - a function of
|
||||
// coinsPerUtxoByte (4310 in this fixture's captured protocol params) and
|
||||
// the output's serialized size - to be the real, size-dependent
|
||||
// constraint, surfaced by Lucid when an output is actually too small.
|
||||
//
|
||||
// Empirically (this fixture, this Lucid version): a plain single-address,
|
||||
// no-asset, no-datum output bottoms out at 849,070 lovelace (~0.84907
|
||||
// AP3X) - close to the plan's own 0.85-1.0 AP3X estimate. Lucid does NOT
|
||||
// throw below that floor; .complete() silently bumps the coin value up to
|
||||
// it instead, which would make a smaller test amount here measure Lucid's
|
||||
// rounding-up behavior instead of the schema-floor change this test
|
||||
// exists to pin. 0.9 AP3X clears that floor with margin, stays under 1
|
||||
// AP3X (the old, now-removed schema minimum) to prove the point, and sits
|
||||
// in the same CBOR uint32 size class as the floor itself (both encode as
|
||||
// 5 bytes), so the floor does not shift between the two values.
|
||||
const r = await buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: 0.9 });
|
||||
const outs = decodeOutputs(r.txCbor);
|
||||
const toRecipient = outs.filter((o) => o.address === FOREIGN_ADDRESS);
|
||||
assert.equal(toRecipient.length, 1);
|
||||
assert.equal(
|
||||
toRecipient[0].lovelace, 900_000n,
|
||||
'sub-1-AP3X send must build offline and carry the exact requested lovelace amount',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSendTokens', () => {
|
||||
@@ -181,4 +227,37 @@ describe('buildSendTokens', () => {
|
||||
/positive/,
|
||||
);
|
||||
});
|
||||
|
||||
test('pins exact lovelace at the call site on the apexAmount path for a fractional amount float math gets wrong', async () => {
|
||||
// Same defect class as buildSendApex's pin above, but 1.005 AP3X
|
||||
// (1,005,000 lovelace) turned out to be BELOW this output's actual
|
||||
// min-UTxO once it carries a native asset - Lucid silently bumped it to
|
||||
// 1,043,020 during .complete(), which would have made this test measure
|
||||
// ledger dust-floor behavior instead of the float-conversion bug it
|
||||
// exists to pin (confirmed empirically: that bump reproduces regardless
|
||||
// of amountApex's exactness, so it is not this defect). Reused the
|
||||
// endorse test's already-verified defect value instead - comfortably
|
||||
// above min-UTxO here too, since the existing 2,000,000n default (see
|
||||
// 'moves the token...' above) is already sufficient for this same
|
||||
// single-token output shape:
|
||||
// 8.03 * 1_000_000 === 8029999.999999999 in IEEE-754 (node -e
|
||||
// "console.log(Math.floor(8.03*1e6))" -> 8029999, one lovelace short of
|
||||
// the correct 8030000). This is the apexAmount branch's OWN call site
|
||||
// (build.ts's buildSendTokens, not buildSendApex), so it needs its own
|
||||
// pin; a shared helper test cannot prove either caller wires it up.
|
||||
const r = await buildSendTokens(lucid, {
|
||||
recipientAddress: FOREIGN_ADDRESS,
|
||||
policyId: FIXTURE_TOKEN_UNIT.slice(0, 56),
|
||||
assetName: FIXTURE_TOKEN_UNIT.slice(56),
|
||||
amount: '50',
|
||||
apexAmount: 8.03,
|
||||
});
|
||||
const outs = decodeOutputs(r.txCbor);
|
||||
const toRecipient = outs.find((o) => o.address === FOREIGN_ADDRESS);
|
||||
assert.ok(toRecipient, 'no output to recipient');
|
||||
assert.equal(
|
||||
toRecipient.lovelace, 8_030_000n,
|
||||
'lost a lovelace - buildSendTokens apexAmount path must call apexToLovelace, not Math.floor(apexAmount * 1_000_000)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,6 +326,31 @@ describe('buildEndorse (offline, FixtureProvider)', () => {
|
||||
assert.equal(endorseOut!.amount().coin(), 7_000_000n, 'exactly-minimum stake (5 AP3X) must still build: 5 + 2 AP3X');
|
||||
});
|
||||
|
||||
test('pins exact lovelace at the call site for a fractional stake float math gets wrong', async () => {
|
||||
// 10.005 and 5.005 (the "obvious" values at each family's minimum) both
|
||||
// happen to be bit-exact under Math.floor(x * 1e6) on this Node/V8 build
|
||||
// (verified: node -e "console.log(Math.floor(10.005*1e6), Math.floor(5.005*1e6))"
|
||||
// -> 10005000 5005000, both correct - neither reproduces the defect).
|
||||
// Scanned upward from the 5 AP3X endorse minimum
|
||||
// for a real loser instead: 8.03 * 1_000_000 === 8029999.999999999 in
|
||||
// IEEE-754 (node -e "console.log(Math.floor(8.03*1e6))" -> 8029999, one
|
||||
// lovelace short of the correct 8030000). This pins buildEndorse's OWN
|
||||
// call site - apexToLovelace itself is already proven exact in
|
||||
// packages/shared/test/tx.test.ts; this test instead catches a future
|
||||
// revert of THIS call site back to Math.floor(stakeApex * 1_000_000).
|
||||
const r = await buildEndorse(lucid, {
|
||||
agentDid: AGENT_DID, proposalTxHash: PROPOSAL_TX, proposalOutputIndex: 3, stakeApex: 8.03,
|
||||
});
|
||||
assert.equal(r.stakeLovelace, '8030000');
|
||||
const tx = decodeTx(r.txCbor);
|
||||
const endorseOut = findOutputTo(tx, r.scriptAddress);
|
||||
assert.ok(endorseOut, 'no output to the endorsement address');
|
||||
assert.equal(
|
||||
endorseOut!.amount().coin(), 10_030_000n,
|
||||
'lost a lovelace - buildEndorse must call apexToLovelace, not Math.floor(stakeApex * 1_000_000): stake (8.03 AP3X = 8,030,000 lovelace) + 2 AP3X rider (2,000,000) = 10,030,000',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a stake below the minimum', async () => {
|
||||
await assert.rejects(
|
||||
buildEndorse(lucid, {
|
||||
|
||||
@@ -471,9 +471,9 @@
|
||||
"type": "string"
|
||||
},
|
||||
"amount": {
|
||||
"description": "Amount of AP3X to send",
|
||||
"description": "Amount of AP3X to send (minimum 0.000001 = 1 lovelace; the ledger's own minimum-UTxO rule applies per output and will reject dust with a clear error)",
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
"minimum": 0.000001
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Optional transaction metadata in JSON format",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucid-evolution/lucid": "^0.4.29",
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"cross-fetch": "^4.0.0"
|
||||
"@noble/hashes": "^1.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,23 @@
|
||||
*
|
||||
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
|
||||
* transaction building/signing without depending on Blockfrost.
|
||||
*
|
||||
* Uses the global `fetch` (native, undici-backed - guaranteed since the node
|
||||
* floor is >=22), not a bundled polyfill. This used to import `fetch` from
|
||||
* `cross-fetch`, whose underlying `node-fetch` embeds the request URL
|
||||
* directly in its own thrown error messages (`request to ${url} failed,
|
||||
* reason: ...`, `invalid json response body at ${url} reason: ...`) - a
|
||||
* leak that bypassed every sanitiser below it, since those only handle the
|
||||
* `!response.ok` case, not a fetch()-level rejection or a response.json()
|
||||
* parse failure. Undici's errors do not embed the URL (verified live on this
|
||||
* Node build: a DNS failure throws `TypeError: fetch failed` with the real
|
||||
* reason on `.cause`, not in `.message`), but every fetch() call and every
|
||||
* `.json()` parse is still wrapped below regardless - a JSON-parse failure
|
||||
* leaks a snippet of the raw body into its own SyntaxError message
|
||||
* (`Unexpected token '<', "<html><bod"... is not valid JSON`, verified live
|
||||
* too) independent of which fetch implementation is in use, and operators
|
||||
* still need the detail this file's console.error calls carry.
|
||||
*/
|
||||
import fetch from 'cross-fetch';
|
||||
import type {
|
||||
Provider,
|
||||
ProtocolParameters,
|
||||
@@ -24,9 +39,13 @@ interface OgmiosProviderConfig {
|
||||
/**
|
||||
* Interpret a submit-api response body as a transaction hash.
|
||||
*
|
||||
* Exported and pure so it can be tested without a network round trip — the
|
||||
* class method that calls it cannot be, because provider.ts imports `fetch`
|
||||
* from cross-fetch rather than reading the global.
|
||||
* Exported and pure so it can be tested without a network round trip. Now
|
||||
* that `provider.ts` reads the global `fetch` instead of importing one from
|
||||
* `cross-fetch`, `OgmiosProvider`'s own methods are also stubbable in tests
|
||||
* (`globalThis.fetch = ...`) - `parseSubmitResponse` stays a separate pure
|
||||
* function anyway, on its own merits: it is reused nowhere near a network
|
||||
* call, and testing the parsing rule in isolation is simpler than testing it
|
||||
* through a live or stubbed HTTP round trip either way.
|
||||
*
|
||||
* Refuses rather than guessing. The previous implementation fell back to the
|
||||
* first 64 characters of the *submitted CBOR*, which is a well-formed 64-char
|
||||
@@ -78,6 +97,136 @@ export function koiosIndicatesConfirmed(data: unknown): boolean {
|
||||
return typeof entry?.num_confirmations === 'number' && entry.num_confirmations > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a downstream HTTP or JSON-RPC failure into a message safe to return
|
||||
* to an untrusted caller: names the service, the operation, and the status -
|
||||
* never the endpoint URL, and never the raw response body.
|
||||
*
|
||||
* On a hosted, multi-tenant deployment the configured Ogmios/Koios/submit-api
|
||||
* endpoint is operator infrastructure, not caller information - and a raw
|
||||
* response body from a misconfigured or unreachable endpoint (a reverse-proxy
|
||||
* error page, for instance) can itself echo back hostnames or other detail
|
||||
* that has no business reaching a caller. Call sites console.error the full
|
||||
* detail (status, body, and which endpoint) themselves before throwing this -
|
||||
* full detail to the operator's log, a clean summary to the caller. That
|
||||
* split is the sanitisation. `status` also accepts a string, for a network
|
||||
* failure or a JSON-RPC error code where there is no HTTP status at all.
|
||||
*
|
||||
* Exported and pure so it can be tested without a network round trip - same
|
||||
* rationale as parseSubmitResponse above.
|
||||
*/
|
||||
export function formatServiceError(
|
||||
service: string,
|
||||
operation: string,
|
||||
status: number | string,
|
||||
statusText?: string,
|
||||
): string {
|
||||
return `${service} request failed (${operation}): ${status}${statusText ? ` ${statusText}` : ''}`;
|
||||
}
|
||||
|
||||
// Longest caller-visible submission-rejection body returned verbatim (after
|
||||
// scrubbing) - bounds how much of a large upstream error page (some
|
||||
// reverse proxies return full HTML) ends up in a tool response.
|
||||
const MAX_SCRUBBED_LENGTH = 2000;
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrub URL-shaped content out of caller-visible text: every http(s) URL,
|
||||
* plus - given a caller-supplied list of known configured endpoints - any
|
||||
* bare occurrence of one of their hostnames, with or without a scheme. Bare
|
||||
* hostnames matter because the shape a raw reverse-proxy or gateway error
|
||||
* page most often uses is not a URL at all ("could not connect to
|
||||
* ogmios.vector.mainnet.apexfusion.org:1337"), which the generic
|
||||
* https?:// pattern alone would miss. Caps the result length so a large
|
||||
* upstream error page cannot flood a tool response.
|
||||
*
|
||||
* Known limitation, stated plainly rather than left for a reader to
|
||||
* discover: this only removes http(s) URLs and whatever hostnames the
|
||||
* caller passes in as `knownHosts` (this file always passes the three
|
||||
* configured Ogmios/Koios/submit-API hosts). A submission-rejection body
|
||||
* that happens to name a DIFFERENT internal host - one this server was
|
||||
* never configured to talk to - passes through unscrubbed, bounded only by
|
||||
* the 2000-character cap above. Closing that fully would mean pattern-
|
||||
* matching arbitrary hostname shapes out of free text, which risks
|
||||
* mangling a legitimate ledger-error string; the three known hosts cover
|
||||
* every endpoint this server itself is configured to reach, which is the
|
||||
* actual leak surface audit E is about.
|
||||
*
|
||||
* Exported and pure so it can be tested without a network round trip - same
|
||||
* rationale as parseSubmitResponse above.
|
||||
*/
|
||||
export function scrubUrls(text: string, knownHosts: string[] = []): string {
|
||||
let scrubbed = text.replace(/https?:\/\/\S+/gi, '[endpoint]');
|
||||
for (const host of knownHosts) {
|
||||
if (!host) continue;
|
||||
let hostname = host;
|
||||
try {
|
||||
hostname = new URL(host).hostname || host;
|
||||
} catch {
|
||||
// Not a full URL - treat the value itself as the bare hostname.
|
||||
}
|
||||
if (!hostname) continue;
|
||||
scrubbed = scrubbed.replace(new RegExp(escapeRegExp(hostname), 'gi'), '[endpoint]');
|
||||
}
|
||||
return scrubbed.length > MAX_SCRUBBED_LENGTH
|
||||
? `${scrubbed.slice(0, MAX_SCRUBBED_LENGTH)} [truncated]`
|
||||
: scrubbed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a transaction-submission rejection for the caller.
|
||||
*
|
||||
* Unlike formatServiceError (an Ogmios/Koios QUERY failure - our own
|
||||
* infrastructure's problem, never the caller's), a submit-api rejection is
|
||||
* the ledger's verdict on the CALLER'S OWN transaction: ValueNotConservedUTxO,
|
||||
* BadInputsUTxO, ScriptWitnessNotValidating, a missing witness, a fee too
|
||||
* small. That reason is caller information, not operator information - it is
|
||||
* the entire feedback loop an agent uses to self-correct after
|
||||
* build -> sign -> submit, and dropping it to a bare status code breaks the
|
||||
* workflow this whole non-custodial migration exists to serve. So the body
|
||||
* survives here, scrubbed of URL-shaped content (scrubUrls) rather than
|
||||
* discarded outright: the body is still attacker-adjacent (an internal or
|
||||
* misconfigured endpoint's own response), even though its content is
|
||||
* otherwise legitimate to return.
|
||||
*
|
||||
* A NETWORK failure to even reach the submit API (no HTTP response at all)
|
||||
* is deliberately NOT routed through this function - see submitTx below. It
|
||||
* is not a ledger verdict on anything, and calling it a "rejection" would be
|
||||
* actively misleading; it goes through formatServiceError instead, the same
|
||||
* as an Ogmios/Koios connection failure.
|
||||
*/
|
||||
export function formatSubmitRejection(
|
||||
status: number,
|
||||
statusText: string,
|
||||
body: string,
|
||||
knownHosts: string[] = [],
|
||||
): string {
|
||||
const suffix = statusText ? ` ${statusText}` : '';
|
||||
return `Transaction submission rejected (${status}${suffix}): ${scrubUrls(body, knownHosts)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an Error (or thrown non-Error value) into one log-friendly string,
|
||||
* including its `.cause` when present - operator-log use only, never for a
|
||||
* caller-visible message. Native fetch (undici) throws are often terse
|
||||
* ("fetch failed") with the actual DNS/connection reason attached as
|
||||
* `.cause` rather than folded into `.message` (verified live: a DNS failure
|
||||
* yields message "fetch failed", cause.message "getaddrinfo ENOTFOUND
|
||||
* <host>") - without this, the operator's console.error would show only the
|
||||
* useless top-level message.
|
||||
*/
|
||||
function describeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
const causeText = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : undefined;
|
||||
return causeText ? `${err.message} (cause: ${causeText})` : err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export class OgmiosProvider implements Provider {
|
||||
private ogmiosUrl: string;
|
||||
private submitUrl: string;
|
||||
@@ -91,10 +240,31 @@ export class OgmiosProvider implements Provider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request to Ogmios
|
||||
* The three configured endpoints, for scrubUrls' knownHosts parameter -
|
||||
* defensive: a shared reverse proxy in front of more than one of these
|
||||
* services could in principle echo a SIBLING service's hostname in its own
|
||||
* error page, so every caller-visible scrub pass considers all three, not
|
||||
* just the one endpoint actually being called.
|
||||
*/
|
||||
private knownHosts(): string[] {
|
||||
return [this.ogmiosUrl, this.submitUrl, this.koiosUrl].filter((u): u is string => Boolean(u));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request to Ogmios.
|
||||
*
|
||||
* Every failure mode a bare `await fetch(...)` can produce is caught here
|
||||
* and rethrown sanitised: a rejected fetch (network/DNS failure - the
|
||||
* class of error node-fetch used to embed this.ogmiosUrl into, see the
|
||||
* file header), a non-OK HTTP response, a response body that is not valid
|
||||
* JSON, and a well-formed JSON-RPC error object. Full detail (including
|
||||
* the URL) always goes to console.error first; only a sanitised summary is
|
||||
* ever thrown.
|
||||
*/
|
||||
private async rpc(method: string, params?: unknown): Promise<any> {
|
||||
const response = await fetch(this.ogmiosUrl, {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.ogmiosUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -104,15 +274,28 @@ export class OgmiosProvider implements Provider {
|
||||
id: null,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Ogmios network error (${method}) at ${this.ogmiosUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Ogmios', method, 'network error'));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Ogmios RPC error (${response.status}): ${text}`);
|
||||
console.error(`[OgmiosProvider] Ogmios RPC error (${method}) at ${this.ogmiosUrl}: ${response.status} ${response.statusText} - ${text}`);
|
||||
throw new Error(formatServiceError('Ogmios', method, response.status, response.statusText));
|
||||
}
|
||||
|
||||
let json: any;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Ogmios response parse error (${method}) at ${this.ogmiosUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Ogmios', method, 'invalid response'));
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (json.error) {
|
||||
throw new Error(`Ogmios RPC error: ${JSON.stringify(json.error)}`);
|
||||
console.error(`[OgmiosProvider] Ogmios RPC error (${method}) at ${this.ogmiosUrl}:`, json.error);
|
||||
throw new Error(formatServiceError('Ogmios', method, json.error?.code ?? 'error', json.error?.message ?? 'RPC error'));
|
||||
}
|
||||
|
||||
return json.result;
|
||||
@@ -204,7 +387,11 @@ export class OgmiosProvider implements Provider {
|
||||
const policyId = unit.slice(0, 56);
|
||||
const assetName = unit.slice(56);
|
||||
|
||||
// Try Koios indexer first (fastest path)
|
||||
// Try Koios indexer first (fastest path). Every failure here - network,
|
||||
// non-OK, or unparseable body - is logged for operators and then falls
|
||||
// through to the generic "not found" throw below, which names none of
|
||||
// it: the caller only ever learns the asset was not found, not why the
|
||||
// Koios attempt failed.
|
||||
if (this.koiosUrl) {
|
||||
try {
|
||||
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
|
||||
@@ -217,9 +404,11 @@ export class OgmiosProvider implements Provider {
|
||||
if (data.length > 0) {
|
||||
return this.koiosUtxoToLucid(data[0]);
|
||||
}
|
||||
} else {
|
||||
console.error(`[OgmiosProvider] Koios asset_utxos query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Koios unreachable - caller can fall back to scanning UTxOs
|
||||
console.error(`[OgmiosProvider] Koios asset_utxos error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +448,8 @@ export class OgmiosProvider implements Provider {
|
||||
}
|
||||
|
||||
async getDelegation(rewardAddress: string): Promise<Delegation> {
|
||||
// Try Koios first if available
|
||||
// Try Koios first if available - same swallow-to-default shape as
|
||||
// getUtxoByUnit above, now with operator visibility on the way in.
|
||||
if (this.koiosUrl) {
|
||||
try {
|
||||
const response = await fetch(`${this.koiosUrl}/api/v1/account_info`, {
|
||||
@@ -275,16 +465,18 @@ export class OgmiosProvider implements Provider {
|
||||
rewards: BigInt(data[0].rewards_available || 0),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.error(`[OgmiosProvider] Koios account_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios account_info error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
return { poolId: null, rewards: 0n };
|
||||
}
|
||||
|
||||
async getDatum(datumHash: string): Promise<string> {
|
||||
// Try Koios if available
|
||||
// Try Koios if available - same shape again.
|
||||
if (this.koiosUrl) {
|
||||
try {
|
||||
const response = await fetch(`${this.koiosUrl}/api/v1/datum_info`, {
|
||||
@@ -297,9 +489,11 @@ export class OgmiosProvider implements Provider {
|
||||
if (data.length > 0 && data[0].bytes) {
|
||||
return data[0].bytes;
|
||||
}
|
||||
} else {
|
||||
console.error(`[OgmiosProvider] Koios datum_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios datum_info error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Datum not found for hash: ${datumHash}. Datum lookups require Koios indexer.`);
|
||||
@@ -312,11 +506,11 @@ export class OgmiosProvider implements Provider {
|
||||
* `koiosIndicatesConfirmed` above, kept separate so it can be unit tested
|
||||
* without a network round trip. Unlike the Koios branch inside `awaitTx`
|
||||
* below (which swallows every failure into "keep polling"), this throws on
|
||||
* a network failure or a non-OK response: the caller could not determine
|
||||
* status, which is a different situation from "asked, and it is not
|
||||
* confirmed yet", and callers such as `pollUntilConfirmed`
|
||||
* a network failure, a non-OK response, or an unparseable body: the caller
|
||||
* could not determine status, which is a different situation from "asked,
|
||||
* and it is not confirmed yet", and callers such as `pollUntilConfirmed`
|
||||
* (packages/builder/src/vector/poll.ts) need to be able to tell the two
|
||||
* apart.
|
||||
* apart. Every throw here is sanitised the same way rpc()'s is.
|
||||
*/
|
||||
async getKoiosTxStatus(txHash: string): Promise<unknown> {
|
||||
if (!this.koiosUrl) {
|
||||
@@ -326,16 +520,28 @@ export class OgmiosProvider implements Provider {
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ _tx_hashes: [txHash] }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios network error (tx_status) at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Koios', 'tx_status', 'network error'));
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Koios tx_status query failed (${response.status}): ${text}`);
|
||||
console.error(`[OgmiosProvider] Koios tx_status query failed at ${this.koiosUrl}: ${response.status} ${response.statusText} - ${text}`);
|
||||
throw new Error(formatServiceError('Koios', 'tx_status', response.status, response.statusText));
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios response parse error (tx_status) at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Koios', 'tx_status', 'invalid response'));
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,7 +555,9 @@ export class OgmiosProvider implements Provider {
|
||||
* indistinguishable from "checked and it is not there yet". Left
|
||||
* unmodified rather than deleted or reworked: TypeScript requires it to
|
||||
* satisfy `implements Provider`, and changing its own retry/error
|
||||
* semantics is out of scope here since nothing depends on them.
|
||||
* semantics is out of scope here since nothing depends on them. The two
|
||||
* catch blocks now log for operator visibility (they did not before);
|
||||
* the swallow-and-continue-polling behavior itself is unchanged.
|
||||
*/
|
||||
async awaitTx(txHash: string, checkInterval: number = 3000): Promise<boolean> {
|
||||
// Poll for tx confirmation
|
||||
@@ -371,8 +579,8 @@ export class OgmiosProvider implements Provider {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue polling
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] awaitTx Koios poll error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
}
|
||||
} else {
|
||||
// Without Koios, try querying the UTxO by outRef
|
||||
@@ -381,8 +589,8 @@ export class OgmiosProvider implements Provider {
|
||||
if (utxos.length > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Not yet confirmed, continue
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] awaitTx getUtxosByOutRef poll error: ${describeError(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +631,15 @@ export class OgmiosProvider implements Provider {
|
||||
|
||||
/**
|
||||
* Get transaction history for an address via Koios.
|
||||
*
|
||||
* Step 1 (address_txs) is load-bearing: any failure - network, non-OK, or
|
||||
* unparseable body - is sanitised and thrown, same shape as rpc()/
|
||||
* getKoiosTxStatus(). Step 2 (tx_info) is optional detail on top of a
|
||||
* result Step 1 already produced: any failure there - network, non-OK, or
|
||||
* unparseable body - degrades to the basic tx list instead of failing the
|
||||
* whole call, extending what used to be only a `!response.ok` fallback to
|
||||
* cover every failure mode, with operator logging on each path (none of
|
||||
* which existed before this change).
|
||||
*/
|
||||
async getTransactionHistory(
|
||||
address: string,
|
||||
@@ -437,18 +654,31 @@ export class OgmiosProvider implements Provider {
|
||||
}
|
||||
|
||||
// Step 1: Get tx hashes for the address
|
||||
const txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
||||
let txListResponse: Response;
|
||||
try {
|
||||
txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ _addresses: [address], _after_block_height: 0 }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios network error (address_txs) at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Koios', 'address_txs', 'network error'));
|
||||
}
|
||||
|
||||
if (!txListResponse.ok) {
|
||||
const text = await txListResponse.text();
|
||||
throw new Error(`Koios address_txs query failed (${txListResponse.status}): ${text}`);
|
||||
console.error(`[OgmiosProvider] Koios address_txs query failed at ${this.koiosUrl}: ${txListResponse.status} ${txListResponse.statusText} - ${text}`);
|
||||
throw new Error(formatServiceError('Koios', 'address_txs', txListResponse.status, txListResponse.statusText));
|
||||
}
|
||||
|
||||
const txList: any[] = await txListResponse.json();
|
||||
let txList: any[];
|
||||
try {
|
||||
txList = await txListResponse.json();
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios response parse error (address_txs) at ${this.koiosUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Koios', 'address_txs', 'invalid response'));
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const paginatedTxs = txList.slice(offset, offset + limit);
|
||||
@@ -457,17 +687,7 @@ export class OgmiosProvider implements Provider {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Fetch full tx details
|
||||
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
||||
const txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ _tx_hashes: txHashes }),
|
||||
});
|
||||
|
||||
if (!txInfoResponse.ok) {
|
||||
// Fall back to basic tx list without details
|
||||
return paginatedTxs.map((tx: any) => ({
|
||||
const basicTxList = () => paginatedTxs.map((tx: any) => ({
|
||||
txHash: tx.tx_hash,
|
||||
blockHeight: tx.block_height || 0,
|
||||
blockTime: tx.block_time
|
||||
@@ -475,9 +695,35 @@ export class OgmiosProvider implements Provider {
|
||||
: '',
|
||||
fee: '0',
|
||||
}));
|
||||
|
||||
// Step 2: Fetch full tx details - optional enrichment, falls back to the
|
||||
// basic list above on any failure rather than failing the whole call.
|
||||
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
||||
let txInfoResponse: Response;
|
||||
try {
|
||||
txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ _tx_hashes: txHashes }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios network error (tx_info) at ${this.koiosUrl}: ${describeError(err)} - falling back to basic tx list`);
|
||||
return basicTxList();
|
||||
}
|
||||
|
||||
const txInfos: any[] = await txInfoResponse.json();
|
||||
if (!txInfoResponse.ok) {
|
||||
const text = await txInfoResponse.text().catch(() => '');
|
||||
console.error(`[OgmiosProvider] Koios tx_info query failed at ${this.koiosUrl}: ${txInfoResponse.status} ${txInfoResponse.statusText} - ${text} - falling back to basic tx list`);
|
||||
return basicTxList();
|
||||
}
|
||||
|
||||
let txInfos: any[];
|
||||
try {
|
||||
txInfos = await txInfoResponse.json();
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Koios response parse error (tx_info) at ${this.koiosUrl}: ${describeError(err)} - falling back to basic tx list`);
|
||||
return basicTxList();
|
||||
}
|
||||
|
||||
return txInfos.map((info: any) => ({
|
||||
txHash: info.tx_hash,
|
||||
@@ -499,20 +745,46 @@ export class OgmiosProvider implements Provider {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A NETWORK failure to reach the submit API (fetch() itself rejects, no
|
||||
* HTTP response was ever received) is not a ledger verdict on anything -
|
||||
* it is this server's own infrastructure failing to talk to the submit
|
||||
* endpoint, the same class of problem as an Ogmios/Koios connection
|
||||
* failure. It is sanitised through formatServiceError, not
|
||||
* formatSubmitRejection: calling an unreachable endpoint a "rejection"
|
||||
* would misleadingly imply the ledger looked at the transaction and said
|
||||
* no, when nothing looked at it at all. An actual non-OK HTTP response
|
||||
* from the submit API IS the ledger's verdict, and keeps going through
|
||||
* formatSubmitRejection exactly as before.
|
||||
*/
|
||||
async submitTx(tx: string): Promise<string> {
|
||||
// tx is a hex-encoded CBOR transaction
|
||||
// submit-api expects raw CBOR bytes with Content-Type: application/cbor
|
||||
const bytes = Buffer.from(tx, 'hex');
|
||||
|
||||
const response = await fetch(this.submitUrl, {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.submitUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/cbor' },
|
||||
body: bytes,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[OgmiosProvider] Submit API network error at ${this.submitUrl}: ${describeError(err)}`);
|
||||
throw new Error(formatServiceError('Submit API', 'submitTx', 'network error'));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Transaction submission failed (${response.status}): ${errorText}`);
|
||||
console.error(`[OgmiosProvider] Transaction submission failed at ${this.submitUrl}: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
throw new Error(
|
||||
formatSubmitRejection(
|
||||
response.status,
|
||||
response.statusText,
|
||||
errorText,
|
||||
this.knownHosts(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.text();
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
// from the signer, which must have no network capability.
|
||||
import { blake2b } from '@noble/hashes/blake2b';
|
||||
|
||||
/**
|
||||
* AP3X (decimal) to lovelace (integer), exactly.
|
||||
*
|
||||
* Never multiply by 1e6 in floating point: e.g. 1.005 * 1_000_000 is
|
||||
* 1004999.9999999999 in IEEE-754, and Math.floor then silently loses a
|
||||
* lovelace (empirically confirmed - see tx.test.ts). Amounts are money;
|
||||
* they get string-exact conversion instead.
|
||||
*
|
||||
* The string comes from Number.prototype.toString(), NOT toFixed(): toFixed
|
||||
* expands the double's true binary value out to the requested digit count,
|
||||
* which reintroduces the exact noise this function exists to avoid (e.g.
|
||||
* (2.05).toFixed(20) is "2.04999999999999982236" - the wrong digits after
|
||||
* the 2nd decimal place). toString() instead yields the shortest decimal
|
||||
* string that round-trips back to the same double, which for a value typed
|
||||
* or parsed from a decimal literal like "2.05" reliably reproduces "2.05".
|
||||
*
|
||||
* More than 6 decimal places is a caller error, not something to round
|
||||
* away silently - AP3X divides no finer than 1 lovelace (1e-6 AP3X).
|
||||
*/
|
||||
export function apexToLovelace(amountApex: number): bigint {
|
||||
if (typeof amountApex !== 'number' || !Number.isFinite(amountApex)) {
|
||||
throw new Error(`Amount must be a finite number, got ${String(amountApex)}`);
|
||||
}
|
||||
if (amountApex < 0) {
|
||||
throw new Error(`Amount must not be negative, got ${amountApex}`);
|
||||
}
|
||||
const str = amountApex.toString();
|
||||
if (/e/i.test(str)) {
|
||||
// Only reachable outside 1e-6 <= amountApex < 1e21: below one lovelace
|
||||
// or beyond any realistic AP3X amount either way. Reject explicitly
|
||||
// rather than let the plain split-on-'.' below misparse the exponent.
|
||||
throw new Error(`Amount is out of range: ${amountApex}`);
|
||||
}
|
||||
const [whole, frac = ''] = str.split('.');
|
||||
if (frac.length > 6) {
|
||||
throw new Error(
|
||||
`Amount has more than 6 decimal places (${amountApex}); AP3X is divisible to 6 places (1 lovelace).`
|
||||
);
|
||||
}
|
||||
return BigInt(whole) * 1_000_000n + BigInt(frac.padEnd(6, '0'));
|
||||
}
|
||||
|
||||
export function lovelaceToAda(lovelace: string | number | bigint): string {
|
||||
if (lovelace === undefined || lovelace === null) return '0.000000';
|
||||
try {
|
||||
|
||||
144
packages/shared/test/provider-error-format.test.ts
Normal file
144
packages/shared/test/provider-error-format.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatServiceError, scrubUrls, formatSubmitRejection } from '../src/provider.ts';
|
||||
import { VECTOR_OGMIOS_URL, VECTOR_KOIOS_URL, VECTOR_SUBMIT_URL } from '../src/config.ts';
|
||||
|
||||
// The concern (audit E): a hosted deployment's internal Ogmios/Koios/submit-api
|
||||
// endpoint reaching an untrusted caller through error text.
|
||||
//
|
||||
// Two distinct cases, two distinct answers:
|
||||
//
|
||||
// - An Ogmios/Koios QUERY failure is OUR infrastructure's problem, never the
|
||||
// caller's - the caller cannot act on it, so it is sanitised down to
|
||||
// service name + status only (formatServiceError). Never the URL, never
|
||||
// the response body.
|
||||
// - A submit-api REJECTION is the ledger's verdict on the CALLER'S OWN
|
||||
// transaction (ValueNotConservedUTxO, BadInputsUTxO, a missing witness,
|
||||
// fee too small) - that reason is caller information, the feedback loop an
|
||||
// agent needs to self-correct after build -> sign -> submit. It survives,
|
||||
// scrubbed of URL-shaped content only (formatSubmitRejection + scrubUrls),
|
||||
// since the body is still attacker-adjacent even though its content is
|
||||
// otherwise legitimate to return.
|
||||
|
||||
describe('formatServiceError', () => {
|
||||
test('names the service, operation, and HTTP status', () => {
|
||||
assert.equal(
|
||||
formatServiceError('Ogmios', 'queryLedgerState/utxo', 503, 'Service Unavailable'),
|
||||
'Ogmios request failed (queryLedgerState/utxo): 503 Service Unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('covers Koios with the same shape', () => {
|
||||
assert.equal(
|
||||
formatServiceError('Koios', 'tx_status', 502, 'Bad Gateway'),
|
||||
'Koios request failed (tx_status): 502 Bad Gateway',
|
||||
);
|
||||
});
|
||||
|
||||
test('omits the status text when the caller has none (a JSON-RPC error code, not an HTTP status)', () => {
|
||||
assert.equal(
|
||||
formatServiceError('Ogmios', 'queryLedgerState/utxo', -32602),
|
||||
'Ogmios request failed (queryLedgerState/utxo): -32602',
|
||||
);
|
||||
});
|
||||
|
||||
test('never contains any configured endpoint URL', () => {
|
||||
// Structural, not incidental: the function has no parameter for a URL or
|
||||
// a response body, so it cannot forward either - but pin it against the
|
||||
// actual values in config.ts (the ones a hosted deployment sets to its
|
||||
// own internal endpoints) so a future signature change that threads one
|
||||
// through fails this test by name.
|
||||
const messages = [
|
||||
formatServiceError('Ogmios', 'queryLedgerState/utxo', 503, 'Service Unavailable'),
|
||||
formatServiceError('Koios', 'address_txs', 500, 'Internal Server Error'),
|
||||
];
|
||||
for (const url of [VECTOR_OGMIOS_URL, VECTOR_KOIOS_URL, VECTOR_SUBMIT_URL]) {
|
||||
for (const msg of messages) {
|
||||
assert.ok(!msg.includes(url), `message leaked a configured endpoint URL: ${msg}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('never contains a raw response body, only what the caller passes as status/statusText', () => {
|
||||
// Simulates the shape of a real leak: an upstream failure whose body
|
||||
// echoes infrastructure detail no caller should see. formatServiceError
|
||||
// is never given the body, so it cannot reproduce it - this pins that a
|
||||
// future edit cannot casually splice ${text} back into the template.
|
||||
// (This is deliberately NOT true of formatSubmitRejection below - a
|
||||
// submit-api body is caller information, scrubbed rather than dropped.)
|
||||
const leakyBody = 'upstream connect error: could not connect to ogmios-internal.vector.svc.cluster.local:1337';
|
||||
const msg = formatServiceError('Ogmios', 'queryLedgerState/utxo', 502, 'Bad Gateway');
|
||||
assert.ok(!msg.includes(leakyBody));
|
||||
assert.ok(!msg.includes('cluster.local'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrubUrls', () => {
|
||||
test('replaces a full http(s) URL with [endpoint]', () => {
|
||||
const text = 'upstream connect error: could not connect to https://ogmios.internal.example.com:1337/health';
|
||||
const scrubbed = scrubUrls(text);
|
||||
assert.ok(!scrubbed.includes('https://'));
|
||||
assert.ok(!scrubbed.includes('ogmios.internal.example.com'));
|
||||
assert.ok(scrubbed.includes('[endpoint]'));
|
||||
});
|
||||
|
||||
test('replaces a bare configured host with no scheme, given knownHosts', () => {
|
||||
// The shape a raw reverse-proxy error page most often actually uses -
|
||||
// no "https://" prefix at all, just the bare hostname (optionally with a
|
||||
// port), which the generic URL pattern alone would miss.
|
||||
const text = 'upstream connect error: could not connect to ogmios.vector.mainnet.apexfusion.org:1337';
|
||||
const scrubbed = scrubUrls(text, ['https://ogmios.vector.mainnet.apexfusion.org']);
|
||||
assert.ok(!scrubbed.includes('ogmios.vector.mainnet.apexfusion.org'), `host survived scrubbing: ${scrubbed}`);
|
||||
assert.ok(scrubbed.includes('[endpoint]'));
|
||||
});
|
||||
|
||||
test('accepts a bare hostname (no scheme) in knownHosts too', () => {
|
||||
const text = 'DNS lookup failed for koios.vector.testnet.apexfusion.org';
|
||||
const scrubbed = scrubUrls(text, ['koios.vector.testnet.apexfusion.org']);
|
||||
assert.ok(!scrubbed.includes('koios.vector.testnet.apexfusion.org'));
|
||||
});
|
||||
|
||||
test('leaves ordinary text with no URL-shaped content untouched', () => {
|
||||
assert.equal(scrubUrls('ValueNotConservedUTxO: inputs 5000000, outputs 5100000'), 'ValueNotConservedUTxO: inputs 5000000, outputs 5100000');
|
||||
});
|
||||
|
||||
test('caps the result length so a large upstream page cannot flood a response', () => {
|
||||
const huge = 'x'.repeat(5000);
|
||||
const scrubbed = scrubUrls(huge);
|
||||
assert.ok(scrubbed.length < 5000, `expected truncation, got length ${scrubbed.length}`);
|
||||
assert.ok(scrubbed.endsWith('[truncated]'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSubmitRejection', () => {
|
||||
test('message shape names the rejection, the status, and keeps the body', () => {
|
||||
assert.equal(
|
||||
formatSubmitRejection(400, 'Bad Request', 'BadInputsUTxO'),
|
||||
'Transaction submission rejected (400 Bad Request): BadInputsUTxO',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a representative ledger reason - the exact feedback loop a caller needs to self-correct', () => {
|
||||
const body = '{"code":"ValueNotConservedUTxO","message":"inputs 5000000 lovelace, outputs 5100000 lovelace"}';
|
||||
const msg = formatSubmitRejection(400, 'Bad Request', body);
|
||||
assert.ok(msg.includes('ValueNotConservedUTxO'), 'ledger rejection reason must survive for the caller to self-correct');
|
||||
assert.ok(msg.includes('5100000'));
|
||||
});
|
||||
|
||||
test('removes a URL embedded in that same body while keeping the ledger reason next to it', () => {
|
||||
const body = 'ValueNotConservedUTxO at https://ogmios.vector.mainnet.apexfusion.org/internal-detail: inputs 5000000, outputs 5100000';
|
||||
const msg = formatSubmitRejection(400, 'Bad Request', body);
|
||||
assert.ok(msg.includes('ValueNotConservedUTxO'), 'ledger reason must survive');
|
||||
assert.ok(msg.includes('5100000'), 'ledger reason detail must survive');
|
||||
assert.ok(!msg.includes('https://'), 'URL scheme must not survive');
|
||||
assert.ok(!msg.includes('ogmios.vector.mainnet.apexfusion.org'), 'endpoint host must not survive');
|
||||
});
|
||||
|
||||
test('scrubs every configured endpoint passed as knownHosts, not just the submit one', () => {
|
||||
const body = `mirrored via ${VECTOR_OGMIOS_URL} and ${VECTOR_KOIOS_URL} - BadInputsUTxO`;
|
||||
const msg = formatSubmitRejection(400, 'Bad Request', body, [VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL]);
|
||||
assert.ok(msg.includes('BadInputsUTxO'));
|
||||
assert.ok(!msg.includes(VECTOR_OGMIOS_URL));
|
||||
assert.ok(!msg.includes(VECTOR_KOIOS_URL));
|
||||
});
|
||||
});
|
||||
188
packages/shared/test/provider-fetch-safety.test.ts
Normal file
188
packages/shared/test/provider-fetch-safety.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { OgmiosProvider } from '../src/provider.ts';
|
||||
|
||||
// The critical this file exists to close: OgmiosProvider used to import
|
||||
// `fetch` from `cross-fetch` (-> node-fetch@2.7.0), which embeds the
|
||||
// request URL directly in its own thrown error messages - a leak that
|
||||
// bypassed every sanitiser in provider-error-format.test.ts, since those
|
||||
// only cover the `!response.ok` case, never a fetch()-level rejection or a
|
||||
// response.json() parse failure. Now that provider.ts reads the global
|
||||
// `fetch`, this file can stub it directly and pin the throw sites for the
|
||||
// first time - see the docblock at the top of provider.ts for the full
|
||||
// story, including the live-verified node-fetch source lines and the
|
||||
// live-verified shape of native fetch's own errors (which do NOT embed the
|
||||
// URL, but a JSON-parse failure still leaks a body snippet regardless of
|
||||
// fetch implementation - also live-verified, reproduced in the fixtures
|
||||
// below).
|
||||
|
||||
const OGMIOS_URL = 'https://ogmios-internal.example.cluster.local:1337';
|
||||
const SUBMIT_URL = 'https://submit-internal.example.cluster.local:1338/api/submit/tx';
|
||||
const KOIOS_URL = 'https://koios-internal.example.cluster.local:1339';
|
||||
const HOSTNAME = 'ogmios-internal.example.cluster.local';
|
||||
|
||||
function testProvider(): OgmiosProvider {
|
||||
return new OgmiosProvider({ ogmiosUrl: OGMIOS_URL, submitUrl: SUBMIT_URL, koiosUrl: KOIOS_URL });
|
||||
}
|
||||
|
||||
async function withStubbedFetch<T>(impl: typeof fetch, fn: () => Promise<T>): Promise<T> {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduces the exact shape observed live on this Node build:
|
||||
// `fetch('http://<unreachable host>')` rejects with `TypeError: fetch
|
||||
// failed`, the real DNS/connection reason attached as `.cause`, NOT folded
|
||||
// into `.message`. (`Object.assign` instead of the two-arg Error
|
||||
// constructor - packages/shared's tsconfig targets es2020, whose ambient
|
||||
// `Error` type has no `cause`-accepting overload; provider.ts's own
|
||||
// `describeError` reads `.cause` the same untyped way for the same reason.)
|
||||
function networkErrorFetch(): typeof fetch {
|
||||
return (async () => {
|
||||
throw Object.assign(new TypeError('fetch failed'), {
|
||||
cause: new Error(`getaddrinfo ENOTFOUND ${HOSTNAME}`),
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
// Reproduces the exact shape observed live: a 200 response whose body is
|
||||
// HTML (a misconfigured reverse proxy's own error page), not JSON -
|
||||
// response.json() throws a real SyntaxError quoting a snippet of the body.
|
||||
function htmlBodyFetch(): typeof fetch {
|
||||
return (async () => new Response(
|
||||
`<html><body>502 Bad Gateway from ${HOSTNAME}</body></html>`,
|
||||
{ status: 200, headers: { 'Content-Type': 'text/html' } },
|
||||
)) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function okJsonFetch(body: unknown): typeof fetch {
|
||||
return (async () => new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('OgmiosProvider - fetch-level failures never leak the endpoint (audit E, critical)', () => {
|
||||
test('a network error (rejected fetch) on an Ogmios query yields no URL, no hostname, no raw DNS detail', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(networkErrorFetch(), () => testProvider().getNetworkTip()),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes('http://') && !err.message.includes('https://'), `leaked a URL scheme: ${err.message}`);
|
||||
assert.ok(!err.message.includes(HOSTNAME), `leaked the hostname: ${err.message}`);
|
||||
assert.ok(!err.message.includes('ENOTFOUND'), `leaked the raw DNS error: ${err.message}`);
|
||||
assert.equal(err.message, 'Ogmios request failed (queryNetwork/tip): network error');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed (HTML) response body on an Ogmios query yields no URL, no body snippet', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(htmlBodyFetch(), () => testProvider().getNetworkTip()),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes('<html>'), `leaked the raw body: ${err.message}`);
|
||||
assert.ok(!err.message.includes(HOSTNAME), `leaked the hostname: ${err.message}`);
|
||||
assert.equal(err.message, 'Ogmios request failed (queryNetwork/tip): invalid response');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an Ogmios JSON-RPC structured error is still sanitised (unaffected by this round, re-pinned here)', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(okJsonFetch({ error: { code: -32602, message: 'Invalid params' } }), () => testProvider().getNetworkTip()),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes(HOSTNAME));
|
||||
assert.equal(err.message, 'Ogmios request failed (queryNetwork/tip): -32602 Invalid params');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a network error on submitTx yields no URL, no hostname, and is NOT worded as a "rejection"', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(networkErrorFetch(), () => testProvider().submitTx('ab'.repeat(32))),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes('http://') && !err.message.includes('https://'));
|
||||
assert.ok(!err.message.includes('cluster.local'), `leaked the hostname: ${err.message}`);
|
||||
assert.equal(err.message, 'Submit API request failed (submitTx): network error');
|
||||
// A network failure never reached the ledger - calling it a
|
||||
// "rejection" would misleadingly imply a verdict was ever rendered.
|
||||
assert.ok(!err.message.toLowerCase().includes('rejected'), `network failure worded as a ledger rejection: ${err.message}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a network error on a Koios query (getKoiosTxStatus) yields no URL, no hostname', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(networkErrorFetch(), () => testProvider().getKoiosTxStatus('a'.repeat(64))),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes('cluster.local'));
|
||||
assert.equal(err.message, 'Koios request failed (tx_status): network error');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed response body on getKoiosTxStatus yields no URL, no body snippet', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(htmlBodyFetch(), () => testProvider().getKoiosTxStatus('a'.repeat(64))),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes('<html>'));
|
||||
assert.equal(err.message, 'Koios request failed (tx_status): invalid response');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a network error on getTransactionHistory (address_txs, the load-bearing call) yields no URL, no hostname', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(networkErrorFetch(), () => testProvider().getTransactionHistory('addr1qtest')),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes('cluster.local'));
|
||||
assert.equal(err.message, 'Koios request failed (address_txs): network error');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a Koios failure on a swallow-to-fallback method (getDelegation) degrades quietly, no throw, no leak possible', async () => {
|
||||
const result = await withStubbedFetch(networkErrorFetch(), () => testProvider().getDelegation('stake1uxxxxxxx'));
|
||||
assert.deepEqual(result, { poolId: null, rewards: 0n });
|
||||
});
|
||||
|
||||
test('a Koios failure on a swallow-to-fallback method (getUtxoByUnit) still reaches the generic not-found message, no leak', async () => {
|
||||
await assert.rejects(
|
||||
() => withStubbedFetch(networkErrorFetch(), () => testProvider().getUtxoByUnit('a'.repeat(56) + 'deadbeef')),
|
||||
(err: Error) => {
|
||||
assert.ok(!err.message.includes(HOSTNAME));
|
||||
assert.match(err.message, /not found/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the happy path is unaffected: a normal successful response still resolves correctly', async () => {
|
||||
const tip = await withStubbedFetch(
|
||||
okJsonFetch({ result: { slot: 12345, id: 'a'.repeat(64) } }),
|
||||
() => testProvider().getNetworkTip(),
|
||||
);
|
||||
assert.equal(tip.slot, 12345);
|
||||
assert.equal(tip.hash, 'a'.repeat(64));
|
||||
});
|
||||
|
||||
test('the happy path for submitTx is unaffected: a normal successful response still resolves correctly', async () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
const txHash = await withStubbedFetch(
|
||||
(async () => new Response(hash, { status: 200 })) as unknown as typeof fetch,
|
||||
() => testProvider().submitTx('ab'.repeat(32)),
|
||||
);
|
||||
assert.equal(txHash, hash);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deriveNftAssetName, lovelaceToAda, formatAssetName, metadataStr } from '../src/tx.ts';
|
||||
import { deriveNftAssetName, lovelaceToAda, formatAssetName, metadataStr, apexToLovelace } from '../src/tx.ts';
|
||||
|
||||
const VECTORS: Array<{ tx: string; idx: number; expected: string }> = [
|
||||
{ tx: '00'.repeat(32), idx: 0, expected: 'a2e5e227858e84f1a8f9b0c1246e6cbc9336d707d43ba43d0e1cb7c51c45f4c9' },
|
||||
@@ -39,3 +39,44 @@ describe('metadataStr', () => {
|
||||
assert.deepEqual((out as string[]).map(s => s.length), [64, 64, 22]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apexToLovelace', () => {
|
||||
test('converts whole and fractional amounts exactly', () => {
|
||||
assert.equal(apexToLovelace(1), 1_000_000n);
|
||||
assert.equal(apexToLovelace(0.000001), 1n);
|
||||
assert.equal(apexToLovelace(25), 25_000_000n);
|
||||
assert.equal(apexToLovelace(3.14), 3_140_000n);
|
||||
// 4.35 and 8.87 happen to be bit-exact under Math.floor(x * 1e6) on this
|
||||
// Node/V8 build (verified empirically - see below for values that are
|
||||
// NOT exact), but a string-exact helper must get them right regardless
|
||||
// of platform, so they stay as correctness checks.
|
||||
assert.equal(apexToLovelace(4.35), 4_350_000n);
|
||||
assert.equal(apexToLovelace(8.87), 8_870_000n);
|
||||
});
|
||||
|
||||
test('is exact where float math is not', () => {
|
||||
// Empirically confirmed on this codebase's Node runtime (node -e):
|
||||
// 2.05 * 1e6 === 2049999.9999999998 -> Math.floor === 2049999 (want 2050000)
|
||||
// 8.11 * 1e6 === 8109999.999999999 -> Math.floor === 8109999 (want 8110000)
|
||||
// 1.005 * 1e6 === 1004999.9999999999 -> Math.floor === 1004999 (want 1005000)
|
||||
// Each of these silently drops exactly 1 lovelace under the old
|
||||
// Math.floor(amountApex * 1_000_000) path. The string-exact helper must not.
|
||||
assert.equal(apexToLovelace(2.05), 2_050_000n);
|
||||
assert.equal(apexToLovelace(8.11), 8_110_000n);
|
||||
assert.equal(apexToLovelace(1.005), 1_005_000n);
|
||||
});
|
||||
|
||||
test('rejects more than 6 decimal places instead of truncating', () => {
|
||||
assert.throws(() => apexToLovelace(1.0000001), /decimal/i);
|
||||
});
|
||||
|
||||
test('rejects negative, NaN, and Infinity', () => {
|
||||
assert.throws(() => apexToLovelace(-1), /positive|negative/i);
|
||||
assert.throws(() => apexToLovelace(NaN), /finite|number/i);
|
||||
assert.throws(() => apexToLovelace(Infinity), /finite|number/i);
|
||||
});
|
||||
|
||||
test('handles large amounts without precision loss', () => {
|
||||
assert.equal(apexToLovelace(1_000_000), 1_000_000_000_000n);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apexfusion/vector-mcp-shared": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
51
scripts/link-libsodium.mjs
Normal file
51
scripts/link-libsodium.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cross-platform replacement for the old postinstall one-liner:
|
||||
*
|
||||
* ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs \
|
||||
* node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs \
|
||||
* 2>/dev/null || true
|
||||
*
|
||||
* `ln -sf` has no reliable equivalent on stock Windows (symlinks need an elevated
|
||||
* privilege Windows does not grant by default), and the trailing `|| true` swallowed
|
||||
* that failure silently - `npm install` reported success while leaving a dangling
|
||||
* ESM import that only surfaces later as ERR_MODULE_NOT_FOUND at runtime, far from
|
||||
* the install step that actually caused it. This script COPIES the file instead
|
||||
* (identical result on every platform) and never swallows a genuine failure: it
|
||||
* exits non-zero and prints why whenever it cannot leave the tree in a working
|
||||
* state.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const SOURCE = 'node_modules/libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs';
|
||||
const DEST = 'node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs';
|
||||
|
||||
if (!existsSync(SOURCE)) {
|
||||
console.error(
|
||||
`link-libsodium: source missing at "${SOURCE}" - is libsodium-sumo installed? Run npm install.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existsSync(DEST) && readFileSync(DEST).equals(readFileSync(SOURCE))) {
|
||||
console.log(`link-libsodium: "${DEST}" already matches the source, nothing to do.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(dirname(DEST))) {
|
||||
console.error(
|
||||
`link-libsodium: destination directory missing at "${dirname(DEST)}" - is libsodium-wrappers-sumo installed correctly? Run npm install.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
copyFileSync(SOURCE, DEST);
|
||||
} catch (err) {
|
||||
console.error(`link-libsodium: failed to copy "${SOURCE}" to "${DEST}": ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`link-libsodium: copied "${SOURCE}" to "${DEST}".`);
|
||||
Reference in New Issue
Block a user