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
|
### 1. Install and build
|
||||||
|
|
||||||
|
Requires Node.js >= 22 (matches `engines` in every `package.json` and the Dockerfile's
|
||||||
|
`node:22-alpine` base).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
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)
|
### 2. Configure environment (optional)
|
||||||
|
|
||||||
```bash
|
```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
|
> echoes a token value. Commas delimit entries and cannot be escaped, so generate
|
||||||
> tokens from a comma-free charset (hex / base64url / alphanumeric).
|
> 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
|
### Mainnet endpoints
|
||||||
|
|
||||||
| Variable | Mainnet value |
|
| 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",
|
"type": "module",
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": ["packages/*"],
|
||||||
"scripts": {
|
"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",
|
"clean": "rm -rf packages/*/build",
|
||||||
"build": "npm run build --workspace=@apexfusion/vector-mcp-builder && npm run build --workspace=@apexfusion/vector-mcp-signer",
|
"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",
|
"typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p packages/builder && tsc --noEmit -p packages/signer",
|
||||||
@@ -24,8 +24,12 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"tar": "^7.5.21",
|
||||||
|
"brace-expansion": "^2.0.2"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "Apex Fusion",
|
"author": "Apex Fusion",
|
||||||
|
|||||||
@@ -19,11 +19,13 @@
|
|||||||
"@lucid-evolution/lucid": "^0.4.29",
|
"@lucid-evolution/lucid": "^0.4.29",
|
||||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1",
|
||||||
"cross-fetch": "^4.0.0",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@apexfusion/vector-mcp-shared": "*"
|
"@apexfusion/vector-mcp-shared": "*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ ${capList}
|
|||||||
**Troubleshooting Tips:**
|
**Troubleshooting Tips:**
|
||||||
1. Verify the DID format: did:vector:agent:{policyId}:{nftAssetName}
|
1. Verify the DID format: did:vector:agent:{policyId}:{nftAssetName}
|
||||||
2. The agent may not be registered or may have deregistered
|
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)}
|
text: `Failed to discover agents: ${err instanceof Error ? err.message : String(err)}
|
||||||
|
|
||||||
**Troubleshooting Tips:**
|
**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
|
2. The registry may be empty - no agents have registered yet
|
||||||
3. Try without filters to list all agents`,
|
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:**
|
**Troubleshooting Tips:**
|
||||||
1. Ensure the changeAddress wallet holds at least 12 AP3X (10 deposit + fees)
|
1. Ensure the changeAddress wallet holds at least 12 AP3X (10 deposit + fees)
|
||||||
2. Each wallet can register multiple agents (different one-shot UTxOs)
|
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,
|
validatorToScriptHash, getAddressDetails,
|
||||||
} from '@lucid-evolution/lucid';
|
} from '@lucid-evolution/lucid';
|
||||||
import type { LucidEvolution, Provider, SpendingValidator, TxSignBuilder } 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 {
|
import type {
|
||||||
TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult,
|
TxOutput, VectorUnsignedBuildResult, VectorBuildDeployResult, VectorBuildInteractResult,
|
||||||
} from '@apexfusion/vector-mcp-shared/types';
|
} 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) {
|
if (typeof p.amountApex !== 'number' || !Number.isFinite(p.amountApex) || p.amountApex <= 0) {
|
||||||
throw new Error('Amount must be a positive number');
|
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 });
|
let tx = lucid.newTx().pay.ToAddress(p.recipientAddress, { lovelace });
|
||||||
if (p.metadataJson) {
|
if (p.metadataJson) {
|
||||||
tx = tx.attachMetadata(674, JSON.parse(p.metadataJson));
|
tx = tx.attachMetadata(674, JSON.parse(p.metadataJson));
|
||||||
@@ -87,7 +87,7 @@ export async function buildSendTokens(
|
|||||||
assetNameHex = fromText(p.assetName);
|
assetNameHex = fromText(p.assetName);
|
||||||
}
|
}
|
||||||
const unit = `${p.policyId}${assetNameHex}`;
|
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, {
|
const tx = lucid.newTx().pay.ToAddress(p.recipientAddress, {
|
||||||
lovelace: outputLovelace,
|
lovelace: outputLovelace,
|
||||||
[unit]: BigInt(p.amount),
|
[unit]: BigInt(p.amount),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
|
|||||||
import { blake2b } from '@noble/hashes/blake2b';
|
import { blake2b } from '@noble/hashes/blake2b';
|
||||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
||||||
|
import { apexToLovelace } from '@apexfusion/vector-mcp-shared/tx';
|
||||||
import type {
|
import type {
|
||||||
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
|
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
|
||||||
} from '@apexfusion/vector-mcp-shared/types';
|
} from '@apexfusion/vector-mcp-shared/types';
|
||||||
@@ -387,7 +388,7 @@ export async function buildCritique(lucid: LucidEvolution, p: {
|
|||||||
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
|
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
|
||||||
throw new Error(`Critique stake must be at least ${MIN_CRITIQUE_STAKE_APEX} AP3X.`);
|
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 finalHash = p.critiqueHash;
|
||||||
let finalUri = p.storageUri;
|
let finalUri = p.storageUri;
|
||||||
@@ -473,7 +474,7 @@ export async function buildEndorse(lucid: LucidEvolution, p: {
|
|||||||
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
|
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
|
||||||
throw new Error(`Endorsement stake must be at least ${MIN_ENDORSE_STAKE_APEX} AP3X.`);
|
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);
|
const vkeyHash = paymentKeyHashOf(changeAddress);
|
||||||
|
|
||||||
@@ -532,7 +533,7 @@ export async function buildProposalLock(lucid: LucidEvolution, p: {
|
|||||||
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
|
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
|
||||||
throw new Error(`Proposal stake must be at least ${MIN_PROPOSAL_STAKE_APEX} AP3X.`);
|
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
|
// Proposal-type datum. Same Constr indices as the custodial switch - the
|
||||||
// deployed module validator parses these exact bytes.
|
// deployed module validator parses these exact bytes.
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
|
|||||||
|
|
||||||
**Troubleshooting Tips:**
|
**Troubleshooting Tips:**
|
||||||
1. Verify the module contracts are deployed on this network
|
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`,
|
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."),
|
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...)"),
|
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"),
|
metadata: z.string().optional().describe("Optional transaction metadata in JSON format"),
|
||||||
},
|
},
|
||||||
async ({ changeAddress, recipientAddress, amount, metadata }) => {
|
async ({ changeAddress, recipientAddress, amount, metadata }) => {
|
||||||
@@ -590,7 +590,7 @@ ${txList}
|
|||||||
text: `Failed to get transaction history: ${error.message}
|
text: `Failed to get transaction history: ${error.message}
|
||||||
|
|
||||||
**Troubleshooting Tips:**
|
**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
|
2. Verify the address is valid
|
||||||
3. Check the block explorer for this address`,
|
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: 0 }), /positive/);
|
||||||
await assert.rejects(() => buildSendApex(lucid, { recipientAddress: FOREIGN_ADDRESS, amountApex: -5 }), /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', () => {
|
describe('buildSendTokens', () => {
|
||||||
@@ -181,4 +227,37 @@ describe('buildSendTokens', () => {
|
|||||||
/positive/,
|
/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');
|
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 () => {
|
test('rejects a stake below the minimum', async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
buildEndorse(lucid, {
|
buildEndorse(lucid, {
|
||||||
|
|||||||
@@ -471,9 +471,9 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"amount": {
|
"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",
|
"type": "number",
|
||||||
"minimum": 1
|
"minimum": 0.000001
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Optional transaction metadata in JSON format",
|
"description": "Optional transaction metadata in JSON format",
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucid-evolution/lucid": "^0.4.29",
|
"@lucid-evolution/lucid": "^0.4.29",
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1"
|
||||||
"cross-fetch": "^4.0.0"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,23 @@
|
|||||||
*
|
*
|
||||||
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
|
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
|
||||||
* transaction building/signing without depending on Blockfrost.
|
* 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 {
|
import type {
|
||||||
Provider,
|
Provider,
|
||||||
ProtocolParameters,
|
ProtocolParameters,
|
||||||
@@ -24,9 +39,13 @@ interface OgmiosProviderConfig {
|
|||||||
/**
|
/**
|
||||||
* Interpret a submit-api response body as a transaction hash.
|
* Interpret a submit-api response body as a transaction hash.
|
||||||
*
|
*
|
||||||
* Exported and pure so it can be tested without a network round trip — the
|
* Exported and pure so it can be tested without a network round trip. Now
|
||||||
* class method that calls it cannot be, because provider.ts imports `fetch`
|
* that `provider.ts` reads the global `fetch` instead of importing one from
|
||||||
* from cross-fetch rather than reading the global.
|
* `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
|
* 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
|
* 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;
|
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 {
|
export class OgmiosProvider implements Provider {
|
||||||
private ogmiosUrl: string;
|
private ogmiosUrl: string;
|
||||||
private submitUrl: string;
|
private submitUrl: string;
|
||||||
@@ -91,28 +240,62 @@ 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> {
|
private async rpc(method: string, params?: unknown): Promise<any> {
|
||||||
const response = await fetch(this.ogmiosUrl, {
|
let response: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
response = await fetch(this.ogmiosUrl, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
jsonrpc: '2.0',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method,
|
body: JSON.stringify({
|
||||||
params: params || {},
|
jsonrpc: '2.0',
|
||||||
id: null,
|
method,
|
||||||
}),
|
params: params || {},
|
||||||
});
|
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) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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) {
|
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;
|
return json.result;
|
||||||
@@ -204,7 +387,11 @@ export class OgmiosProvider implements Provider {
|
|||||||
const policyId = unit.slice(0, 56);
|
const policyId = unit.slice(0, 56);
|
||||||
const assetName = unit.slice(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) {
|
if (this.koiosUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
|
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
|
||||||
@@ -217,9 +404,11 @@ export class OgmiosProvider implements Provider {
|
|||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
return this.koiosUtxoToLucid(data[0]);
|
return this.koiosUtxoToLucid(data[0]);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[OgmiosProvider] Koios asset_utxos query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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> {
|
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) {
|
if (this.koiosUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.koiosUrl}/api/v1/account_info`, {
|
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),
|
rewards: BigInt(data[0].rewards_available || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[OgmiosProvider] Koios account_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Fall through to default
|
console.error(`[OgmiosProvider] Koios account_info error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { poolId: null, rewards: 0n };
|
return { poolId: null, rewards: 0n };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDatum(datumHash: string): Promise<string> {
|
async getDatum(datumHash: string): Promise<string> {
|
||||||
// Try Koios if available
|
// Try Koios if available - same shape again.
|
||||||
if (this.koiosUrl) {
|
if (this.koiosUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.koiosUrl}/api/v1/datum_info`, {
|
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) {
|
if (data.length > 0 && data[0].bytes) {
|
||||||
return data[0].bytes;
|
return data[0].bytes;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[OgmiosProvider] Koios datum_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Fall through
|
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.`);
|
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
|
* `koiosIndicatesConfirmed` above, kept separate so it can be unit tested
|
||||||
* without a network round trip. Unlike the Koios branch inside `awaitTx`
|
* without a network round trip. Unlike the Koios branch inside `awaitTx`
|
||||||
* below (which swallows every failure into "keep polling"), this throws on
|
* below (which swallows every failure into "keep polling"), this throws on
|
||||||
* a network failure or a non-OK response: the caller could not determine
|
* a network failure, a non-OK response, or an unparseable body: the caller
|
||||||
* status, which is a different situation from "asked, and it is not
|
* could not determine status, which is a different situation from "asked,
|
||||||
* confirmed yet", and callers such as `pollUntilConfirmed`
|
* 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
|
* (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> {
|
async getKoiosTxStatus(txHash: string): Promise<unknown> {
|
||||||
if (!this.koiosUrl) {
|
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;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
|
||||||
body: JSON.stringify({ _tx_hashes: [txHash] }),
|
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) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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
|
* indistinguishable from "checked and it is not there yet". Left
|
||||||
* unmodified rather than deleted or reworked: TypeScript requires it to
|
* unmodified rather than deleted or reworked: TypeScript requires it to
|
||||||
* satisfy `implements Provider`, and changing its own retry/error
|
* 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> {
|
async awaitTx(txHash: string, checkInterval: number = 3000): Promise<boolean> {
|
||||||
// Poll for tx confirmation
|
// Poll for tx confirmation
|
||||||
@@ -371,8 +579,8 @@ export class OgmiosProvider implements Provider {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Continue polling
|
console.error(`[OgmiosProvider] awaitTx Koios poll error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Without Koios, try querying the UTxO by outRef
|
// Without Koios, try querying the UTxO by outRef
|
||||||
@@ -381,8 +589,8 @@ export class OgmiosProvider implements Provider {
|
|||||||
if (utxos.length > 0) {
|
if (utxos.length > 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Not yet confirmed, continue
|
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.
|
* 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(
|
async getTransactionHistory(
|
||||||
address: string,
|
address: string,
|
||||||
@@ -437,18 +654,31 @@ export class OgmiosProvider implements Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get tx hashes for the address
|
// Step 1: Get tx hashes for the address
|
||||||
const txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
let txListResponse: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
||||||
body: JSON.stringify({ _addresses: [address], _after_block_height: 0 }),
|
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) {
|
if (!txListResponse.ok) {
|
||||||
const text = await txListResponse.text();
|
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
|
// Apply pagination
|
||||||
const paginatedTxs = txList.slice(offset, offset + limit);
|
const paginatedTxs = txList.slice(offset, offset + limit);
|
||||||
@@ -457,27 +687,43 @@ export class OgmiosProvider implements Provider {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fetch full tx details
|
const basicTxList = () => paginatedTxs.map((tx: any) => ({
|
||||||
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
txHash: tx.tx_hash,
|
||||||
const txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
blockHeight: tx.block_height || 0,
|
||||||
method: 'POST',
|
blockTime: tx.block_time
|
||||||
headers: { 'Content-Type': 'application/json' },
|
? new Date(tx.block_time * 1000).toISOString()
|
||||||
body: JSON.stringify({ _tx_hashes: txHashes }),
|
: '',
|
||||||
});
|
fee: '0',
|
||||||
|
}));
|
||||||
|
|
||||||
if (!txInfoResponse.ok) {
|
// Step 2: Fetch full tx details - optional enrichment, falls back to the
|
||||||
// Fall back to basic tx list without details
|
// basic list above on any failure rather than failing the whole call.
|
||||||
return paginatedTxs.map((tx: any) => ({
|
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
||||||
txHash: tx.tx_hash,
|
let txInfoResponse: Response;
|
||||||
blockHeight: tx.block_height || 0,
|
try {
|
||||||
blockTime: tx.block_time
|
txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
||||||
? new Date(tx.block_time * 1000).toISOString()
|
method: 'POST',
|
||||||
: '',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
fee: '0',
|
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) => ({
|
return txInfos.map((info: any) => ({
|
||||||
txHash: info.tx_hash,
|
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> {
|
async submitTx(tx: string): Promise<string> {
|
||||||
// tx is a hex-encoded CBOR transaction
|
// tx is a hex-encoded CBOR transaction
|
||||||
// submit-api expects raw CBOR bytes with Content-Type: application/cbor
|
// submit-api expects raw CBOR bytes with Content-Type: application/cbor
|
||||||
const bytes = Buffer.from(tx, 'hex');
|
const bytes = Buffer.from(tx, 'hex');
|
||||||
|
|
||||||
const response = await fetch(this.submitUrl, {
|
let response: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/cbor' },
|
response = await fetch(this.submitUrl, {
|
||||||
body: bytes,
|
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) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
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();
|
const result = await response.text();
|
||||||
|
|||||||
@@ -2,6 +2,48 @@
|
|||||||
// from the signer, which must have no network capability.
|
// from the signer, which must have no network capability.
|
||||||
import { blake2b } from '@noble/hashes/blake2b';
|
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 {
|
export function lovelaceToAda(lovelace: string | number | bigint): string {
|
||||||
if (lovelace === undefined || lovelace === null) return '0.000000';
|
if (lovelace === undefined || lovelace === null) return '0.000000';
|
||||||
try {
|
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 { describe, test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 }> = [
|
const VECTORS: Array<{ tx: string; idx: number; expected: string }> = [
|
||||||
{ tx: '00'.repeat(32), idx: 0, expected: 'a2e5e227858e84f1a8f9b0c1246e6cbc9336d707d43ba43d0e1cb7c51c45f4c9' },
|
{ 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]);
|
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": {
|
"devDependencies": {
|
||||||
"@apexfusion/vector-mcp-shared": "*"
|
"@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