fix(stellar): make SEP-41 asset metadata reads resilient - #216
Conversation
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.
|
Nice direction, but the tests mock ScVal accessors as properties. Real Stellar ScVal uses methods such as |
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.
|
@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! 🚀 |
|
Thanks, you're right, and it was worse than bad metadata. On a real Fixed in e5769ed:
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.
|
|
Merged. The real ScVal tests now cover the original bug and full i128 balances. Thanks @aratass. |
Closes #213.
The bug
getAssetMetadataread the three SEP-41 fields withPromise.all:One absent method discards the two that answered. A token that implements
symbolanddecimalsbut notnameis 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 genericError.The result type
getAssetMetadataResultis added alongside the existing function:Each failure carries the field and a reason:
missing,invalidorrpc-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
classifySimulationErrorreads the text. Anything unrecognised isrpc-error, deliberately: treating an unknown failure asmissingwould 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'sscValToNative:name,symbolscvString,scvSymboldecimalsscvU32balancescvI128invalidfailure. The union's accessors (str(),sym(),u32(),i128()) throw when called on the wrong arm, so without the check a wrong type surfaced asrpc-errorand told the caller to retry a contract that will never answer correctly.missing, the same as a simulation without a result.decimalsoutside 0 to 18 isinvalid, and a test asserts that noNaNreaches the metadata object.rpc.Api.SimulateTransactionResponse, so the compiler checks this path. The previous property reads (scv.sym,scv.i128) were only possible throughas any.Only complete results are cached
The cache previously stored whatever came back. Now it stores only a
completeresult. 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 anunsupportedverdict 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
getAssetBalancecannot work ondevelopat all:contract.callas 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 asAddress.fromString(address).toScVal(), which is what SEP-41'sbalance(id: Address)takes. A fullStrKeycheck runs first, so a bad checksum still gets the typedUnsupportedAssetError.(scv as any).i128?.lo?.()with a|| '0'fallback: on a real ScVali128is a method, so this isundefined, and even a well-formed request would have read every balance as zero. With thelovalue it was meant to read, it would still have dropped the high 64 bits.scValToNativenow 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 realxdr.ScVal, serialised and parsed back byrpc.parseRawSimulation, which is whatsimulateTransactionreturns, so string values arrive as Buffers just as they do from a live RPC.test/chains/stellar/asset.resilience.test.ts(30 tests) covers:MissingValuehost error, no result row, a void return), RPC failures, and wrong types (scvU32,scvBool,scvI32,scvI128, andscvStringwhereu32is declared);Symbolin place ofString, and decimals 0 and 18;scvAddress, and a bad-checksum address rejected before any RPC call.The nine tests in
test/chains/stellar/asset.test.tskeep 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
getAssetMetadatakeeps 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 thenameone, with the same message.Verification
Run the way CI runs it: pnpm 10,
pnpm install --frozen-lockfile, then each CI step.develop@92eb209Zero failures on either side. The 30 extra passes are
test/chains/stellar/asset.resilience.test.ts.pnpm run format:checkis clean,pnpm buildsucceeds,pnpm api:checkpasses (etc/sdk-stellar.api.mdwas regenerated in the second commit, and the third commit does not change it), andpnpm sizepasses.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.