Skip to content

fix(stellar): make SEP-41 asset metadata reads resilient - #216

Merged
truthixify merged 3 commits into
wraith-protocol:developfrom
aratass:fix/sep41-resilience
Sep 25, 2026
Merged

truthixify merged 3 commits into
wraith-protocol:developfrom
aratass:fix/sep41-resilience

Conversation

@aratass

@aratass aratass commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Closes #213.

The bug

getAssetMetadata read the three SEP-41 fields with Promise.all:

const [name, symbol, decimals] = await Promise.all([
  callContractMethod<string>(contractId, 'name', [], rpcUrl),
  callContractMethod<string>(contractId, 'symbol', [], rpcUrl),
  callContractMethod<number>(contractId, 'decimals', [], rpcUrl),
]);

One absent method discards the two that answered. A token that implements symbol and decimals but not name is indistinguishable, from the caller's side, from a contract that is not a token at all and from an RPC that was down for three seconds. All three arrive as the same generic Error.

The result type

getAssetMetadataResult is added alongside the existing function:

type AssetMetadataResult =
  | { status: 'complete';    metadata: AssetMetadata;          failures: readonly AssetMetadataFailure[] }
  | { status: 'partial';     metadata: Partial<AssetMetadata>; failures: readonly AssetMetadataFailure[] }
  | { status: 'unsupported'; metadata: Partial<AssetMetadata>; failures: readonly AssetMetadataFailure[] };

Each failure carries the field and a reason: missing, invalid or rpc-error. That last distinction is the one that matters operationally, because it separates "this contract is not a token" from "try again in a minute", and the old code could not express it.

Soroban reports an absent function as a host error rather than a distinct status, so classifySimulationError reads the text. Anything unrecognised is rpc-error, deliberately: treating an unknown failure as missing would let a transient outage be recorded as a verdict about the contract.

Decoding and validation

The type of each return value is checked with scv.switch() against what SEP-41 declares, and the value is then decoded with the SDK's scValToNative:

method accepted ScVal types result
name, symbol scvString, scvSymbol UTF-8 string, must not be empty or whitespace
decimals scvU32 number, must be 0 to 18
balance scvI128 bigint built from both 64-bit halves
  • Any other type is an invalid failure. The union's accessors (str(), sym(), u32(), i128()) throw when called on the wrong arm, so without the check a wrong type surfaced as rpc-error and told the caller to retry a contract that will never answer correctly.
  • A void return value counts as missing, the same as a simulation without a result.
  • A decimals outside 0 to 18 is invalid, and a test asserts that no NaN reaches the metadata object.
  • The simulation response is typed as rpc.Api.SimulateTransactionResponse, so the compiler checks this path. The previous property reads (scv.sym, scv.i128) were only possible through as any.

Only complete results are cached

The cache previously stored whatever came back. Now it stores only a complete result. A partial read caused by a flaky RPC must not pin a half-empty record in front of every call for the five-minute cache lifetime, and an unsupported verdict must not pin a wrong answer either. Two tests cover this: a partial read followed by a successful one performs six RPC calls, not three.

Balance: two defects outside the stated scope

The acceptance criteria say "validate decimals and integer balance responses". Testing against real SDK objects showed that getAssetBalance cannot work on develop at all:

  1. The request. The account was passed to contract.call as a plain string. That cannot be encoded into the transaction (XDR Write Error: ... has union name undefined, not ScVal), so the call failed before it reached the RPC. It is now sent as Address.fromString(address).toScVal(), which is what SEP-41's balance(id: Address) takes. A full StrKey check runs first, so a bad checksum still gets the typed UnsupportedAssetError.
  2. The decoding. (scv as any).i128?.lo?.() with a || '0' fallback: on a real ScVal i128 is a method, so this is undefined, and even a well-formed request would have read every balance as zero. With the lo value it was meant to read, it would still have dropped the high 64 bits. scValToNative now returns the full i128, and a wrong-typed or void response throws instead of reporting zero.

Say the word if you would rather that landed as its own PR and I will split it.

Tests

Both asset test files mock only rpc.Server. Transactions are built by the real SDK. Each reply is a real xdr.ScVal, serialised and parsed back by rpc.parseRawSimulation, which is what simulateTransaction returns, so string values arrive as Buffers just as they do from a live RPC.

test/chains/stellar/asset.resilience.test.ts (30 tests) covers:

  • complete, partial and unsupported results, and that only complete ones are cached;
  • missing methods (Soroban's MissingValue host error, no result row, a void return), RPC failures, and wrong types (scvU32, scvBool, scvI32, scvI128, and scvString where u32 is declared);
  • UTF-8 names, Symbol in place of String, and decimals 0 and 18;
  • balances of 5,000,000, 2^64 + 5, 2^64 - 1 and the i128 maximum, a check that the balance call sends an scvAddress, and a bad-checksum address rejected before any RPC call.

The nine tests in test/chains/stellar/asset.test.ts keep their assertions. Only their mocks changed, from property-style stubs to real ScVal replies.

To check the tests would have caught the bug: with the previous decoder, 33 of the 39 tests in the two files fail.

Backward compatibility

getAssetMetadata keeps its signature and its throwing behaviour, and now delegates. Failures are reported in field order, so the error surfaced for a fully broken contract is still the name one, with the same message.

Verification

Run the way CI runs it: pnpm 10, pnpm install --frozen-lockfile, then each CI step.

test files tests
develop @ 92eb209 77 passed, 2 skipped 1232 passed, 5 skipped
this branch merged with it 78 passed, 2 skipped 1262 passed, 5 skipped

Zero failures on either side. The 30 extra passes are test/chains/stellar/asset.resilience.test.ts. pnpm run format:check is clean, pnpm build succeeds, pnpm api:check passes (etc/sdk-stellar.api.md was regenerated in the second commit, and the third commit does not change it), and pnpm size passes.

Correction to the first version of this description

It quoted a wrong test count and claimed a broken baseline (failing test files, a broken lockfile, Prettier failures). None of that was true of this repository: it came from running pnpm 12 instead of the pnpm 10 this project pins. Sorry for the noise.

getAssetMetadata fetched name, symbol and decimals with Promise.all, so a
contract missing any one method threw and discarded the two that answered,
and a caller could not tell an incomplete token from a non-token or an RPC
outage.

Add getAssetMetadataResult, returning a discriminated complete, partial or
unsupported result with a per-field reason of missing, invalid or rpc-error.
Validate before use: decimals must be an integer 0 to 18, name and symbol must
be non-empty strings, and only a complete result is cached so a flaky RPC
cannot pin a half-empty record for the cache lifetime.

Also correct the balance decoder, which read only the low 64 bits of the i128
and fell back to '0', making any balance at or above 2^64 silently wrong and an
undecodable response indistinguishable from an empty account.

getAssetMetadata keeps its previous signature and throwing behaviour.

Closes wraith-protocol#213
api:check compares the built surface against etc/*.api.md and fails when they
diverge. This adds AssetMetadataResult, AssetMetadataFailure,
AssetMetadataFailureReason, AssetMetadataField and getAssetMetadataResult to the
committed report.
@truthixify

Copy link
Copy Markdown
Contributor

Nice direction, but the tests mock ScVal accessors as properties. Real Stellar ScVal uses methods such as sym(), str(), and i128(), so this code reads function objects and can return bad metadata or fail balance decoding. Please use the real accessors or scValToNative, and add tests with real xdr.ScVal values.

The decoder read `sym`, `str` and `i128` as properties, but on a real
xdr.ScVal they are methods. Against a live RPC every name and symbol came
back as a function object and was rejected as invalid, and the balance
decoder rejected every i128. The tests passed only because they mocked
those accessors as properties.

Check the ScVal type with switch() against what SEP-41 declares (String or
Symbol for name and symbol, u32 for decimals, i128 for balance) and decode
with scValToNative. A wrong type is now an `invalid` failure rather than an
accessor exception reported as an RPC error, and a void return counts as
`missing`. The hand-written string and i128 decoders are removed.

Send the balance account as an Address ScVal. A plain string cannot be
encoded into the transaction, so getAssetBalance failed before reaching the
RPC. The key is checked with StrKey first, so a bad checksum still gets the
typed UnsupportedAssetError.

Both asset test files now mock only rpc.Server. Transactions are built by
the real SDK, and every reply is a real xdr.ScVal passed through XDR and
rpc.parseRawSimulation, the same path a live response takes.
@drips-wave

drips-wave Bot commented Sep 25, 2026

Copy link
Copy Markdown

@aratass Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@aratass

aratass commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, you're right, and it was worse than bad metadata. On a real xdr.ScVal, (scv as any).sym is the accessor function, so every name and symbol was rejected as invalid, and scv.i128 has no .lo, so every balance was rejected too. The property-style mocks hid both.

Fixed in e5769ed:

  • Decoding. The type of each return value is checked with scv.switch() against what SEP-41 declares (String or Symbol for name and symbol, u32 for decimals, i128 for balance) and then decoded with scValToNative. A wrong type is now an invalid failure, where before the accessor threw and it was reported as rpc-error. A void return counts as missing. The hand-written string and i128 decoders are gone, and the simulation response is typed as rpc.Api.SimulateTransactionResponse, so there is no any left on that path.
  • A second bug the real tests found. getAssetBalance passed the account to contract.call as a plain string. That cannot be encoded (XDR Write Error: ... not ScVal), so the call failed before it reached the RPC. It now sends Address.fromString(address).toScVal(), after a full StrKey check so a bad checksum still gets the typed UnsupportedAssetError. This one is already on develop, where the same property reads also return the accessor's source text as the token name.
  • Tests. asset.test.ts and asset.resilience.test.ts now mock only rpc.Server. Transactions are built by the real SDK. Every reply is a real xdr.ScVal (scvString, scvSymbol, scvU32, nativeToScVal(n, { type: 'i128' }), and wrong types such as scvBool, scvI32 and scvVoid), serialised and parsed back by rpc.parseRawSimulation, so string values arrive as Buffers just as they do from a live RPC. Balances cover 2^64 + 5, 2^64 - 1 and the i128 maximum, and one test checks that the balance call sends an scvAddress.

To check the tests would have caught it: with the previous decoder, 33 of the 39 asset tests fail. With the plain-string address, 7 of the 8 balance tests fail; the eighth never reaches the RPC.

format:check, build, api:check (no report change), test and size pass on the branch. They also pass on a local merge with current develop: 1262 passed, 5 skipped, against 1232 on develop alone. CI is green on the new commit.

@truthixify
truthixify merged commit 88647ae into wraith-protocol:develop Sep 25, 2026
16 checks passed
@truthixify

Copy link
Copy Markdown
Contributor

Merged. The real ScVal tests now cover the original bug and full i128 balances. Thanks @aratass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Wave 9] Make SEP-41 asset metadata reads resilient

3 participants