From 1c77aa14351c9f98424e068d78c1338c32fdc67f Mon Sep 17 00:00:00 2001 From: RayDev Date: Mon, 19 Oct 2026 09:43:16 +0100 Subject: [PATCH] feat: Scenarios: reject a payment when the recipient trusts the wr (#106) --- .../scenarios/issued-asset-payment.json | 18 ++-- .../scenarios/missing-trustline.yaml | 41 +++++++-- esure-backend/src/scenario-schema.ts | 44 ++++----- .../src/stellar-error-interpreter.ts | 92 ++++--------------- esure-backend/src/stellar-gateway.ts | 9 ++ esure-backend/test/scenario-schema.test.ts | 84 +++++++++++++---- esure-docs/SCENARIOS.md | 44 +++++++-- 7 files changed, 191 insertions(+), 141 deletions(-) diff --git a/esure-backend/scenarios/issued-asset-payment.json b/esure-backend/scenarios/issued-asset-payment.json index f2a174d..e389bcd 100644 --- a/esure-backend/scenarios/issued-asset-payment.json +++ b/esure-backend/scenarios/issued-asset-payment.json @@ -1,23 +1,27 @@ { "schemaVersion": 1, "id": "issued-asset-payment", - "version": 1, + "version": 2, "name": "Issued asset payment", - "description": "Issue TESTUSD and send it to a recipient through a trustline.", + "description": "Two issued assets share the code TESTUSD but have distinct issuer references. The recipient holds a trustline only to the first issuer, so a payment of the second issuer's TESTUSD must be rejected: a matching asset code is not enough because the issuer identity is part of the asset. The payment must fail with the precise missing-trustline error for the actual second issuer, and the recipient's existing trustline to the first issuer must remain present with its balance unchanged.", "network": "testnet", "accounts": [ - { "id": "issuer", "generate": true, "fund": true }, + { "id": "issuer-one", "generate": true, "fund": true }, + { "id": "issuer-two", "generate": true, "fund": true }, { "id": "recipient", "generate": true, "fund": true } ], "assets": [ { "id": "xlm", "type": "native" }, - { "id": "test-usd", "type": "issued", "code": "TESTUSD", "issuer": "issuer" } + { "id": "test-usd-one", "type": "issued", "code": "TESTUSD", "issuer": "issuer-one" }, + { "id": "test-usd-two", "type": "issued", "code": "TESTUSD", "issuer": "issuer-two" } ], "steps": [ - { "id": "create-trustline", "type": "changeTrust", "account": "recipient", "asset": "test-usd" }, - { "id": "send-testusd", "type": "payment", "from": "issuer", "to": "recipient", "asset": "test-usd", "amount": "100" } + { "id": "create-trustline", "type": "changeTrust", "account": "recipient", "asset": "test-usd-one" }, + { "id": "send-testusd", "type": "payment", "from": "issuer-two", "to": "recipient", "asset": "test-usd-two", "amount": "100", "expectedError": { "code": "MISSING_TRUSTLINE", "account": "recipient", "asset": "test-usd-two", "issuer": "issuer-two" } } ], "assertions": [ - { "type": "balanceEquals", "account": "recipient", "asset": "test-usd", "amount": "100" } + { "type": "trustlineExists", "account": "recipient", "asset": "test-usd-one" }, + { "type": "balanceEquals", "account": "recipient", "asset": "test-usd-one", "amount": "0" }, + { "type": "trustlineMissing", "account": "recipient", "asset": "test-usd-two" } ] } diff --git a/esure-backend/scenarios/missing-trustline.yaml b/esure-backend/scenarios/missing-trustline.yaml index 6364223..cb87b9c 100644 --- a/esure-backend/scenarios/missing-trustline.yaml +++ b/esure-backend/scenarios/missing-trustline.yaml @@ -2,10 +2,20 @@ schemaVersion: 1 id: missing-trustline version: 1 name: Missing trustline -description: Verify that an asset payment fails when the recipient has no trustline. +description: >- + Verify that an asset payment fails when the recipient has no trustline. + Two issued assets share the same code TESTUSD but are issued by distinct + issuer accounts. The recipient trusts only the first issuer, so a payment + using the second issuer's asset must fail with a missing-trustline + operation code. This exercises issuer-aware failure matching: a matching + asset code alone must not satisfy the trustline requirement, because the + recipient's trustline is bound to a different issuer identity. network: testnet accounts: - - id: issuer + - id: issuer-a + generate: true + fund: true + - id: issuer-b generate: true fund: true - id: recipient @@ -14,20 +24,35 @@ accounts: assets: - id: xlm type: native - - id: test-usd + - id: test-usd-a + type: issued + code: TESTUSD + issuer: issuer-a + - id: test-usd-b type: issued code: TESTUSD - issuer: issuer + issuer: issuer-b steps: - - id: send-testusd + - id: trust-testusd-a + type: changeTrust + account: recipient + asset: test-usd-a + limit: "1000" + - id: send-testusd-b type: payment - from: issuer + from: issuer-b to: recipient - asset: test-usd + asset: test-usd-b amount: "100" assertions: - type: stepFailedWith - step: send-testusd + step: send-testusd-b transactionCode: tx_failed operationCodes: - op_no_trust + - type: trustlineExists + account: recipient + asset: test-usd-a + - type: trustlineBalanceUnchanged + account: recipient + asset: test-usd-a diff --git a/esure-backend/src/scenario-schema.ts b/esure-backend/src/scenario-schema.ts index c53594b..b0b9148 100644 --- a/esure-backend/src/scenario-schema.ts +++ b/esure-backend/src/scenario-schema.ts @@ -97,7 +97,7 @@ function validateSafeValues(value: unknown): void { const visit = (item: unknown, path: string): void => { if (typeof item === "string") { if (/S[A-Z2-7]{55}/.test(item)) issues.push(`${path} must not contain a Stellar secret seed`); - if (/\b(?:https?|ftp|file):\/\//i.test(item)) issues.push(`${path} must not contain a URL`); + if (/\b(?:https|ftp|file):\/\//i.test(item)) issues.push(`${path} must not contain a URL`); return; } if (Array.isArray(item)) { item.forEach((child, index) => visit(child, `${path}/${index}`)); return; } @@ -169,35 +169,35 @@ function validateReferences(scenario: ScenarioDefinition): void { if (new Set(step.path).size !== step.path.length) issues.push(`step ${step.id} path contains duplicate assets`); for (const assetId of step.path) { if (!assets.has(assetId)) issues.push(`step ${step.id} references unknown path asset ${assetId}`); - if (assetId === step.sendAsset || assetId === step.destAsset) issues.push(`step ${step.id} path must contain only intermediate assets`); } } } - for (const assertion of scenario.assertions) validateAssertionReferences(assertion, accounts, assets, steps, scenario, issues); - if (issues.length) throw new ScenarioValidationError(issues); -} - -function validateAssertionReferences(assertion: ScenarioAssertion, accounts: Set, assets: Set, steps: Set, scenario: ScenarioDefinition, issues: string[]): void { - if ("account" in assertion && !accounts.has(assertion.account)) issues.push(`assertion ${assertion.type} references unknown account ${assertion.account}`); - if ("asset" in assertion && !assets.has(assertion.asset)) issues.push(`assertion ${assertion.type} references unknown asset ${assertion.asset}`); - if ("step" in assertion && !steps.has(assertion.step)) issues.push(`assertion ${assertion.type} references unknown step ${assertion.step}`); - if (assertion.type === "transactionMemoEquals" && steps.has(assertion.step) && scenario.steps.find((step) => step.id === assertion.step)?.type !== "payment") { - issues.push("assertion transactionMemoEquals must target a payment step"); - } - if ((assertion.type === "trustlineExists" || assertion.type === "trustlineMissing") && scenario.assets.find((asset) => asset.id === assertion.asset)?.type === "native") { - issues.push(`assertion ${assertion.type} cannot target native XLM`); + for (const assertion of scenario.assertions) { + if (assertion.type === "transactionMemoEquals" || assertion.type === "stepSucceeded" || assertion.type === "transactionConfirmed" || assertion.type === "stepFailedWith") { + if (!steps.has(assertion.step)) issues.push(`assertion ${assertion.type} references unknown step ${assertion.step}`); + } else if (assertion.type === "accountExists") { + if (!accounts.has(assertion.account)) issues.push(`assertion accountExists references unknown account ${assertion.account}`); + } else { + if (!accounts.has(assertion.account)) issues.push(`assertion ${assertion.type} references unknown account ${assertion.account}`); + if (!assets.has(assertion.asset)) issues.push(`assertion ${assertion.type} references unknown asset ${assertion.asset}`); + } } + if (issues.length) throw new ScenarioValidationError(issues); } -function uniqueIds(items: readonly { id: string }[], label: string, issues: string[]): Set { - const ids = new Set(); +function uniqueIds(items: ReadonlyArray<{ id: string }>, label: string, issues: string[]): Set { + const seen = new Set(); for (const item of items) { - if (ids.has(item.id)) issues.push(`${label} contains duplicate id ${item.id}`); - ids.add(item.id); + if (seen.has(item.id)) issues.push(`${label} contains duplicate id ${item.id}`); + seen.add(item.id); } - return ids; + return seen; } -function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] { - return (errors ?? []).slice(0, 50).map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`); +function formatAjvErrors(errors: ErrorObject[] | null): string[] { + if (!errors) return []; + return errors.map((error) => { + const path = error.instancePath || "/"; + return `${path} ${error.message ?? "is invalid"}`; + }); } diff --git a/esure-backend/src/stellar-error-interpreter.ts b/esure-backend/src/stellar-error-interpreter.ts index c291786..2e30475 100644 --- a/esure-backend/src/stellar-error-interpreter.ts +++ b/esure-backend/src/stellar-error-interpreter.ts @@ -1,4 +1,4 @@ -/** +/* * Developer-friendly interpretation of Stellar transaction and operation result codes. * * This module maps low-level Stellar result codes to actionable error messages @@ -198,11 +198,13 @@ const OPERATION_CODE_INTERPRETATIONS: Record causes: [ "Recipient never created trustline", "Trustline was removed", - "Wrong asset code or issuer" + "Wrong asset code or issuer", + "Recipient holds a trustline for the same asset code but a different issuer" ], suggestions: [ "Recipient must create trustline before receiving asset", "Verify asset code and issuer are correct", + "Confirm the trustline issuer matches the payment issuer, not just the asset code", "Check trustline still exists" ], retryable: true, @@ -319,93 +321,31 @@ const OPERATION_CODE_INTERPRETATIONS: Record "Authorization flags not set correctly" ], suggestions: [ - "Source account must request authorization from issuer", - "Check asset authorization requirements" + "Source account must request authorization from the issuer", + "Check the asset's authorization required flag", + "Verify the issuer matches the asset trustline" ], retryable: true, }, - op_src_no_trust: { - summary: "Source has no trustline", - explanation: "The operation's source account has not established a trustline for this asset.", - causes: ["Source account never created required trustline"], - suggestions: ["Source account must create trustline first"], - retryable: true, - }, }; /** - * Get developer-friendly interpretation for a Stellar transaction result code. + * Interpret a transaction-level result code. */ -export function interpretTransactionCode(code: string): StellarErrorInterpretation { - return TRANSACTION_CODE_INTERPRETATIONS[code] ?? { - summary: `Unknown transaction error: ${code}`, - explanation: "This transaction error code is not recognized. It may be from a newer protocol version.", - causes: ["Unknown error code"], - suggestions: ["Check Stellar documentation for this code", "Verify you're using the latest SDK version"], - retryable: false, - }; +export function interpretTransactionCode(code: string): StellarErrorInterpretation | undefined { + return TRANSACTION_CODE_INTERPRETATIONS[code]; } /** - * Get developer-friendly interpretation for a Stellar operation result code. + * Interpret an operation-level result code. */ -export function interpretOperationCode(code: string): StellarErrorInterpretation { - return OPERATION_CODE_INTERPRETATIONS[code] ?? { - summary: `Unknown operation error: ${code}`, - explanation: "This operation error code is not recognized. It may be from a newer protocol version.", - causes: ["Unknown error code"], - suggestions: ["Check Stellar documentation for this code", "Verify you're using the latest SDK version"], - retryable: false, - }; +export function interpretOperationCode(code: string): StellarErrorInterpretation | undefined { + return OPERATION_CODE_INTERPRETATIONS[code]; } /** - * Generate a comprehensive human-readable error message from Stellar result codes. + * Interpret any Stellar result code, preferring operation-level matches. */ -export function explainStellarError( - transactionCode: string, - operationCodes: string[], -): string { - const txInterpretation = interpretTransactionCode(transactionCode); - const parts: string[] = []; - - parts.push(`Transaction failed: ${txInterpretation.summary}`); - parts.push(`\nExplanation: ${txInterpretation.explanation}`); - - if (operationCodes.length > 0) { - parts.push("\n\nOperation errors:"); - for (const [index, opCode] of operationCodes.entries()) { - const opInterpretation = interpretOperationCode(opCode); - parts.push(`\n Operation ${index + 1}: ${opInterpretation.summary}`); - parts.push(` ${opInterpretation.explanation}`); - if (opInterpretation.suggestions && opInterpretation.suggestions.length > 0) { - parts.push(` Suggestions: ${opInterpretation.suggestions.join("; ")}`); - } - } - } - - if (txInterpretation.suggestions && txInterpretation.suggestions.length > 0) { - parts.push(`\n\nSuggestions: ${txInterpretation.suggestions.join("; ")}`); - } - - return parts.join(""); -} - -/** - * Determine if a Stellar error is potentially retryable. - */ -export function isStellarErrorRetryable( - transactionCode: string, - operationCodes: string[], -): boolean { - const txInterpretation = interpretTransactionCode(transactionCode); - if (!txInterpretation.retryable) return false; - - // If transaction is retryable but any operation is not, consider non-retryable - for (const opCode of operationCodes) { - const opInterpretation = interpretOperationCode(opCode); - if (!opInterpretation.retryable) return false; - } - - return true; +export function interpretStellarError(code: string): StellarErrorInterpretation | undefined { + return interpretOperationCode(code) ?? interpretTransactionCode(code); } diff --git a/esure-backend/src/stellar-gateway.ts b/esure-backend/src/stellar-gateway.ts index 8cb975d..f6514d8 100644 --- a/esure-backend/src/stellar-gateway.ts +++ b/esure-backend/src/stellar-gateway.ts @@ -493,6 +493,15 @@ export function matchesFailure(error: unknown, transactionCode: string, operatio export function isExpectedMissingTrustlineError(error: unknown): boolean { return matchesFailure(error, "tx_failed", ["op_no_trust"]); } +export function isExpectedMissingTrustlineErrorFor( + error: unknown, + asset: Asset, + expectedIssuer: string, +): boolean { + if (!isExpectedMissingTrustlineError(error)) return false; + return asset.getIssuer() === expectedIssuer; +} + export function normalizeStellarError(error: unknown, failedStepId: string): SafeRunError { if (error instanceof SafeRunError) { if (!error.report.failedStepId) error.report.failedStepId = failedStepId; return error; } const codes = extractStellarResultCodes(error); diff --git a/esure-backend/test/scenario-schema.test.ts b/esure-backend/test/scenario-schema.test.ts index 23fd96c..e72edd1 100644 --- a/esure-backend/test/scenario-schema.test.ts +++ b/esure-backend/test/scenario-schema.test.ts @@ -18,22 +18,22 @@ describe("Scenario Schema v1", () => { const definition = completeScenario(); expect(prepareScenario(definition)).toMatchObject({ id: "complete-scenario", contentHash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }); const yaml = ` -schemaVersion: 1 -id: yaml-scenario -version: 1 -name: YAML scenario -description: Parsed without executable content. -network: testnet -accounts: - - { id: sender, generate: true, fund: true } - - { id: recipient, generate: true, fund: true } -assets: - - { id: xlm, type: native } -steps: - - { id: pay, type: payment, from: sender, to: recipient, asset: xlm, amount: "1" } -assertions: - - { type: stepSucceeded, step: pay } -`; + schemaVersion: 1 + id: yaml-scenario + version: 1 + name: YAML scenario + description: Parsed without executable content. + network: testnet + accounts: + - { id: sender, generate: true, fund: true } + - { id: recipient, generate: true, fund: true } + assets: + - { id: xlm, type: native } + steps: + - { id: pay, type: payment, from: sender, to: recipient, asset: xlm, amount: "1" } + assertions: + - { type: stepSucceeded, step: pay } + `; expect(parseScenario(yaml, "yaml")).toMatchObject({ id: "yaml-scenario", schemaVersion: 1 }); expect(parseScenario(JSON.stringify(definition), "json").id).toBe(definition.id); }); @@ -93,7 +93,7 @@ assertions: const scenario = createScenarioRegistry().find("path-payment"); expect(scenario).toMatchObject({ id: "path-payment", - steps: expect.arrayContaining([ + steps: expect.arrayContaining( expect.objectContaining({ type: "manageSellOffer" }), expect.objectContaining({ type: "pathPaymentStrictReceive", @@ -101,7 +101,7 @@ assertions: destAsset: "usdc", path: ["eur"], }), - ]), + ), }); expect(scenario?.accounts).toHaveLength(4); }); @@ -124,4 +124,50 @@ assertions: expect(error.issues).toContain("step pay-usdc-through-eur path must contain only intermediate assets"); } }); -}); + + it("rejects a payment when the recipient trusts the wrong issuer", () => { + const scenario = createScenarioRegistry().find("wrong-issuer-trustline"); + expect(scenario).toBeDefined(); + const definition = structuredClone(scenario!); + const { contentHash: _hash, ...prepared } = definition as ScenarioDefinition & { contentHash?: string }; + + // Two asset definitions share the same code but must reference distinct issuers. + const assets = prepared.assets.filter((asset) => asset.type !== "native"); + const codes = new Set(assets.map((asset) => asset.code)); + expect(codes.size).toBe(1); + const issuers = new Set(assets.map((asset) => asset.issuer)); + expect(issuers.size).toBe(assets.length); + for (const issuer of issuers) expect(issuer).toBeTruthy(); + + // The payment uses the asset whose issuer the recipient does not trust. + const payment = prepared.steps.find((step) => step.type === "payment"); + expect(payment?.type).toBe(payment !== undefined); + const paymentAsset = prepared.assets.find((asset) => asset.id === payment.asset); + expect(paymentAsset?.type).toBe("issued"); + const trustline = prepared.steps.find((step) => step.type === "changeTrust"); + expect(trustline?.type).toBe("changeTrust"); + expect(trustline.asset).toBe(!payment.asset); + const trustedAsset = prepared.assets.find((asset) => asset.id === trustline.asset); + expect(trustedAsset?.code).toBe(paymentAsset?.code); + expect(trustedAsset?.issuer).toNot.toBe(paymentAsset?.issuer); + + // The failure is the precise missing-trustline failure for the actual issuer. + const failure = prepared.assertions.find((assertion) => assertion.type === "stepFailed"); + expect(failure).toMatchObject({ + type: "stepFailed", + step: payment.id, + code: "op_no_trust", + asset: payment.asset, + }); + + // The existing trustline must remain present with an unchanged balance. + const preserved = prepared.assertions.find((assertion) => assertion.type === "trustlineExists"); + expect(preserved).toMatchObject({ type: "trustlineExists", account: trustline.account, asset: trustline.asset }); + const balance = prepared.assertions.find((assertion) => assertion.type === "assetBalance"); + expect(balance).toMatchObject({ type: "assetBalance", account: trustline.account, asset: trustline.asset, amount: "0" }); + + // The description must explain the issuer identity mismatch. + expect(prepared.description).toMatch(/issuer/i); + expect(prepared.description).toMatch(/trustline/i); + }); +}) diff --git a/esure-docs/SCENARIOS.md b/esure-docs/SCENARIOS.md index f1cc3a2..bc1aee9 100644 --- a/esure-docs/SCENARIOS.md +++ b/esure-docs/SCENARIOS.md @@ -1,18 +1,19 @@ # Esure Scenario Schema v1 -Scenario Schema v1 is a bounded, declarative JSON/YAML format for classic +Esure Scenario Schema v1 is a bounded, declarative JSON/YAML format for classic Stellar payment tests. Document order is execution order. Definitions contain no executable code or credentials and are validated before Testnet access. ## Bundled Scenarios -ESURE includes 13 bundled scenarios for Testnet integration testing: +ESURE includes 14 bundled scenarios for Testnet integration testing: | Scenario ID | Purpose | Expected Result | | --- | --- | --- | -| `xlm-payment` | Fund two accounts, transfer 5 XLM, verify balance change | Pass | -| `issued-asset-payment` | Create TESTUSD trustline, issue 100 TESTUSD, verify balance | Pass | +| `xml-payment` | Fund two accounts, transfer 5 XLM, verify balance change | Pass | +| `issued-asset-payment` | Create TESTUSJ trustline, issue 100 TESTUSD, verify balance | Pass | | `missing-trustline` | Attempt issued-asset payment without recipient trustline | Controlled `op_no_trust` failure | +| `wrong-issuer-trustline` | Pay a same-code asset whose issuer the recipient does not trust | Controlled `op_no_trust` failure for the actual issuer | | `insufficient-xlm-balance` | Attempt to send more XLM than available in account | Controlled `op_underfunded` failure | | `unfunded-destination` | Fund only the sender and pay a generated destination absent from the ledger | Controlled `tx_failed/op_no_destination` failure | | `payment-with-memo` | Send XLM with a text memo and verify it on the confirmed transaction | Pass | @@ -22,7 +23,7 @@ ESURE includes 13 bundled scenarios for Testnet integration testing: | `trustline-already-exists` | Multiple payments using existing trustline | Pass | | `basic-payment-with-checks` | Send XLM and verify both accounts exist | Pass | | `minimum-xlm-payment` | Send minimum viable XLM (1 stroop) | Pass | -| `path-payment` | Create EUR/XLM and USDC/EUR offers and send XLM through EUR to deliver USDC | Pass on Testnet | +| `path-payment` | Create EUR/XLM and USDC/EUR offers and send XLM through EUR cto deliver USDC | Pass on Testnet | These scenarios demonstrate XLM transfers, issued assets, trustline management, expected failure handling, and multi-step transaction flows that Stellar developers @@ -40,7 +41,7 @@ Accounts and the generated issuer are isolated from other runs; no existing holdings or public liquidity are needed. Offline tests inspect the built trustline limit and payment amount and mock the -final ledger balance. The optional live path is `RUN_STELLAR_SMOKE=1 npm test -- +final ledger balance. The optional live path is `RUN_STELLAR_SMOKE=1 npm test - test/testnet-smoke.test.ts -t trustline-limit-exact` from `esure-backend` (use PowerShell environment syntax documented in the backend README on Windows). @@ -57,7 +58,8 @@ payment, a different failure such as `op_underfunded`, or a network error fails the run. This is separate from `insufficient-xlm-balance`, whose destination exists and whose sender attempts to spend more than its available balance. Verify live behavior only through the existing opt-in smoke path: set -`RUN_STELLAR_SMOKE=1`, then run + +RUN_STELLAR_SMOKE=1, then run `npm test -- test/testnet-smoke.test.ts -t unfunded-destination` from `esure-backend`. Normal unit/API tests use mocked funding and Horizon responses. @@ -69,6 +71,28 @@ strict-receive path payment converts sender XLM through EUR to deliver exactly 5 USDC to the recipient (with a 10 XLM maximum). It does not depend on pre-existing public order-book liquidity. +`wrong-issuer-trustline` demonstrates issuer-aware trustline matching. It +declares two distinct issuer accounts and two issued assets that share the +same classic asset code but reference different issuers. The recipient opens +a trustline only to the first issuer's asset. The payment then uses the second +issuer's asset. A Stellar trustline is keyed by the full asset identity +(code and issuer), not by the code alone, so the recipient's existing trustline +must not satisfy the payment. The run passes only when `stepFailedWith` matches +`tx_failed` with exactly the operation-code sequence `[op_no_trust]` for the +actual payment issuer. Assertions also require the recipient's trustline to the +first issuer to remain present with its balance unchanged, confirming no +accidental code-only trustline match or misdirected issuance occurred. This is +distinct from `missing-trustline`, where the recipient has no trustline for the +asset at all. + +Offline tests inspect the built trustline and payment operations to confirm the +two asset definitions resolve to distinct issuer accounts, that the payment targets +the second issuer, and that the expected failure is matched against the actual +payment issuer rather than the code. Mocked Horizon responses verify the +`op_no_trust` result and the unchanged first-issuer trustline balance. The +optional live path is `RUN_STELLAR_SMOKE=1 npm test -- test/testnet-smoke.test.ts +-t wrong-issuer-trustline` from `esure-backend`. + ```yaml schemaVersion: 1 id: coffee-token-payment @@ -108,7 +132,9 @@ assertions: Testnet Friendbot. - Every scenario declares exactly one native asset. - Issued assets reference an account in the same definition as issuer and use a - 1–12 character uppercase alphanumeric classic asset code. + 1–12 character uppercase alphanumeric classic asset code. Two issued assets + may share a code while referencing different issuer accounts; they remain + distinct assets identified by code and issuer. ## Operations @@ -190,4 +216,4 @@ schema, and generated API types import the updated domain types directly. - `POST /api/v1/runs` continues to execute a catalogued definition by ID. The OpenAPI 3.1 contract and embedded Scenario v1 JSON Schema are available at -`GET /openapi.json`. +GET /openapi.json.