Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions test/fixtures/decoder-cases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
[
{
"name": "payment-native",
"why": "The most common operation; the exact sentence must state the amount, asset, and destination.",
"operation": {
"type": "payment",
"params": { "destination": "{DEST}", "asset": "native", "amount": "10.5" }
},
"expectDescription": "Pay 10.5000000 XLM to {DEST}"
},
{
"name": "create-account",
"why": "Exercises the starting-balance branch, distinct from payment.",
"operation": {
"type": "createAccount",
"params": { "destination": "{NEW_ACCOUNT}", "startingBalance": "25" }
},
"expectDescription": "Create account {NEW_ACCOUNT} with starting balance 25.0000000 XLM"
},
{
"name": "manage-data-set",
"why": "Exercises dataValueLabel's printable-text branch (quoted value).",
"operation": {
"type": "manageData",
"params": { "name": "project", "value": "stellar" }
},
"expectDescription": "Set data entry project = \"stellar\""
},
{
"name": "set-options-signer-and-threshold",
"why": "Exercises describeSetOptions' multi-field branch (signer + threshold together), safety-relevant because it changes the account's own multisig setup.",
"operation": {
"type": "setOptions",
"params": { "signer": { "publicKey": "{SIGNER}", "weight": 1 }, "medThreshold": 2 }
},
"expectDescription": "Set account options: master key weight null; low threshold null; medium threshold 2; high threshold null; set flags null; clear flags null; signer {SIGNER} (weight 1)"
},
{
"name": "change-trust",
"why": "Exercises assetLabel's non-native code:issuer branch via a trustline limit change.",
"operation": {
"type": "changeTrust",
"params": { "asset": { "code": "USD", "issuer": "{ISSUER}" }, "limit": "1000" }
},
"expectDescription": "Set trustline for USD:{ISSUER} with limit 1000.0000000"
},
{
"name": "allow-trust",
"why": "Exercises the authorize-flag formatting branch (parsed as a number, not a boolean).",
"operation": {
"type": "allowTrust",
"params": { "trustor": "{TRUSTOR}", "assetCode": "USD", "authorize": true }
},
"expectDescription": "Set trust authorization for USD held by {TRUSTOR} (authorize=1)"
},
{
"name": "account-merge",
"why": "A single-field operation with real safety weight: merging an account is irreversible.",
"operation": {
"type": "accountMerge",
"params": { "destination": "{DEST}" }
},
"expectDescription": "Merge this account into {DEST}"
},
{
"name": "path-payment-strict-send",
"why": "Exercises the dual-asset, dual-amount path-payment branch, distinct from a simple payment.",
"operation": {
"type": "pathPaymentStrictSend",
"params": {
"destination": "{DEST}",
"sendAsset": "native",
"sendAmount": "10",
"destAsset": { "code": "USD", "issuer": "{ISSUER}" },
"destMin": "5"
}
},
"expectDescription": "Send exactly 10.0000000 XLM to {DEST} delivering at least 5.0000000 USD:{ISSUER}"
},
{
"name": "bump-sequence",
"why": "A minimal single-field operation, useful as a baseline for the exhaustiveness switch.",
"operation": {
"type": "bumpSequence",
"params": { "bumpTo": "123456789" }
},
"expectDescription": "Bump sequence number to 123456789"
},
{
"name": "manage-sell-offer",
"why": "Exercises the offer/price formatting branch shared by the DEX operation family.",
"operation": {
"type": "manageSellOffer",
"params": {
"selling": "native",
"buying": { "code": "USD", "issuer": "{ISSUER}" },
"amount": "100",
"price": "1.5"
}
},
"expectDescription": "Place sell offer: sell 100.0000000 XLM for USD:{ISSUER} at price 1.5 (offer 0)"
},
{
"name": "invoke-host-function-fallback",
"why": "Safety case: Soroban operations are not decoded into plain language in v1, and the fallback sentence must never silently disappear or claim more than it knows.",
"operation": {
"type": "invokeHostFunction",
"params": { "kind": "createStellarAssetContract", "asset": { "code": "USD", "issuer": "{ISSUER}" } }
},
"expectDescription": "Invoke a Soroban smart contract function"
}
]
162 changes: 162 additions & 0 deletions test/summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Decoder-case regression tests, driven by test/fixtures/decoder-cases.json.
*
* That file is shared byte-for-byte with the sibling coordinator-web repo's
* test/fixtures/decoder-cases.json. Running the same human-readable cases and
* expected exact sentences through each repo's own decoder does NOT prove the
* two decoders produce identical output to each other in this test run — this
* suite never touches coordinator-web's code. What it proves is narrower but
* still useful: the same input parameters and the same expected exact
* description continue to hold in THIS repo's decoder. If someone changes
* src/summary.ts in a way that changes one of these sentences, this suite
* fails here; if the same drift happens only in the other repo, only that
* repo's copy of this suite will fail. Catching cross-repo drift still
* requires both suites to be kept green independently.
*
* These are additive to the existing decoder coverage in create.test.ts,
* which exercises the decoder through the full HTTP request/response cycle.
* This suite exercises describeTransaction/describeOperation directly.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { Account, Asset, Keypair, Networks, Operation, Transaction, TransactionBuilder } from '@stellar/stellar-sdk';
import { describeTransaction } from '../src/summary.js';

interface AssetParam {
code: string;
issuer: string;
}

type OperationParams = Record<string, unknown>;

interface DecoderCase {
name: string;
why: string;
operation: { type: string; params: OperationParams };
expectDescription: string;
}

const FIXTURE_PATH = fileURLToPath(new URL('./fixtures/decoder-cases.json', import.meta.url));
const cases: DecoderCase[] = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8'));

const PLACEHOLDER_PATTERN = /\{[A-Z_]+\}/g;

/** Every {TOKEN} appearing anywhere in a case gets one fresh, real keypair. */
function resolvePlaceholders(caseData: DecoderCase): Map<string, string> {
const tokens = new Set(JSON.stringify(caseData).match(PLACEHOLDER_PATTERN) ?? []);
const resolved = new Map<string, string>();
for (const token of tokens) {
resolved.set(token, Keypair.random().publicKey());
}
return resolved;
}

function resolveString(value: string, tokens: Map<string, string>): string {
if (tokens.has(value)) return tokens.get(value)!;
return value.replace(PLACEHOLDER_PATTERN, (token) => tokens.get(token) ?? token);
}

function resolveAsset(value: unknown, tokens: Map<string, string>): Asset {
if (value === 'native') return Asset.native();
const { code, issuer } = value as AssetParam;
return new Asset(code, resolveString(issuer, tokens));
}

/**
* Explicit, per-type builder — deliberately not a generic deserializer, so
* each case's SDK call is plainly visible and each new operation type this
* repo wants to cover requires one new, readable branch here.
*/
function buildOperation(operation: DecoderCase['operation'], tokens: Map<string, string>) {
const p = operation.params;
switch (operation.type) {
case 'payment':
return Operation.payment({
destination: resolveString(p.destination as string, tokens),
asset: resolveAsset(p.asset, tokens),
amount: p.amount as string,
});
case 'createAccount':
return Operation.createAccount({
destination: resolveString(p.destination as string, tokens),
startingBalance: p.startingBalance as string,
});
case 'manageData':
return Operation.manageData({
name: p.name as string,
value: Buffer.from(p.value as string, 'utf8'),
});
case 'setOptions': {
const signer = p.signer as { publicKey: string; weight: number };
return Operation.setOptions({
signer: { ed25519PublicKey: resolveString(signer.publicKey, tokens), weight: signer.weight },
medThreshold: p.medThreshold as number,
});
}
case 'changeTrust':
return Operation.changeTrust({
asset: resolveAsset(p.asset, tokens),
limit: p.limit as string,
});
case 'allowTrust':
return Operation.allowTrust({
trustor: resolveString(p.trustor as string, tokens),
assetCode: p.assetCode as string,
authorize: p.authorize as boolean,
});
case 'accountMerge':
return Operation.accountMerge({
destination: resolveString(p.destination as string, tokens),
});
case 'pathPaymentStrictSend':
return Operation.pathPaymentStrictSend({
destination: resolveString(p.destination as string, tokens),
sendAsset: resolveAsset(p.sendAsset, tokens),
sendAmount: p.sendAmount as string,
destAsset: resolveAsset(p.destAsset, tokens),
destMin: p.destMin as string,
});
case 'bumpSequence':
return Operation.bumpSequence({ bumpTo: p.bumpTo as string });
case 'manageSellOffer':
return Operation.manageSellOffer({
selling: resolveAsset(p.selling, tokens),
buying: resolveAsset(p.buying, tokens),
amount: p.amount as string,
price: p.price as string,
});
case 'invokeHostFunction':
// Only the createStellarAssetContract shape is covered — it is the
// simplest real invokeHostFunction operation to build without raw XDR.
return Operation.createStellarAssetContract({
asset: resolveAsset(p.asset, tokens),
});
default:
throw new Error(`decoder-cases.json: no test builder for operation type "${operation.type}"`);
}
}

function buildTransactionForCase(caseData: DecoderCase): { transaction: Transaction; tokens: Map<string, string> } {
const tokens = resolvePlaceholders(caseData);
const source = new Account(Keypair.random().publicKey(), '1234567890');
const transaction = new TransactionBuilder(source, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(buildOperation(caseData.operation, tokens))
.setTimeout(300)
.build();
return { transaction, tokens };
}

describe('decoder cases (shared fixtures)', () => {
for (const caseData of cases) {
it(`${caseData.name}: ${caseData.why}`, () => {
const { transaction, tokens } = buildTransactionForCase(caseData);
const summary = describeTransaction(transaction);

expect(summary.operations).toHaveLength(1);
const operation = summary.operations[0]!;
expect(operation.type).toBe(caseData.operation.type);
expect(operation.description).toBe(resolveString(caseData.expectDescription, tokens));
});
}
});
Loading