Testee protocol
A testee is a small program that wraps one Stellar SDK at one exact version and answers
requests from the conformance runner (shapekit conformance run). The runner sends corpus
data through the SDK's own decoder, encoder, strkey codec, ordering functions and native
projection, and compares what comes back with the network's answer (the corpus manifest) and
with the other SDKs.
Protocol version: 1. The machine-readable form is schema/testee-protocol.schema.json.
Anything this page and the schema disagree on is a bug in one of them.
Transport
- The runner starts the testee as a child process and talks to it over stdin and stdout.
- Every message is one JSON object encoded as UTF-8 on one line, terminated by
\n. No pretty printing, no blank lines, no byte order mark. - The runner sends one request and waits for exactly one response before it sends the next.
Requests and responses are matched by
idas well; a response whoseiddiffers from the outstanding request is a protocol violation. - stdout carries responses only. Logging, warnings and stack traces go to stderr, which the
runner keeps and shows next to any
errorresult. - A line can be large (a full
LedgerCloseMetain base64). Testees must accept request lines of at least 64 MiB. - A response line may be at most 256 MiB (268435456 bytes, not counting the
\n). The runner stops reading a longer line at that size, records a protocol violation (error) and restarts the testee, so a testee that prints without end cannot grow the runner without bound. A response that is not UTF-8 is a protocol violation too. - When stdin reaches end of file the testee exits with status 0.
- The runner enforces a per-request timeout and an address-space or data-segment limit on the
testee process (see "Limits the runner applies"). The timeout covers writing the request as
well as reading the response: a testee that stops reading stdin cannot block the runner on a
request larger than the pipe buffer. A testee that times out, exits or is
killed is restarted, said
helloto again, and the request that was in flight is recorded aserror.
Messages
Request
{"id": "17", "op": "decode_encode", "type": "TransactionEnvelope", "xdr": "AAAAAg...", "depth_limit": 1000, "len_limit": 4096}id: string chosen by the runner, unique within one process lifetime. Echo it back. A line that is not a JSON object with a stringidis answered with"id": ""and abad-requesterror.op: one of the operation names below.- The other members depend on
op. Unknown members must be ignored, so that later protocol versions can add optional members.
Response
Exactly one of three forms:
{"id": "17", "status": "ok", "result": {"xdr": "AAAAAg..."}}
{"id": "17", "status": "error", "error": {"class": "rejected", "message": "xdr value invalid"}}
{"id": "17", "status": "unsupported", "reason": "go-stellar-sdk has no scval map sort"}status: "ok": the SDK produced a value.resultholds the op's result members.status: "error": the SDK, or the testee, did not produce a value.error.classsays why,error.messageis the SDK's own message (free text, never compared).status: "unsupported": the SDK version under test has no API for this operation, or for this particular input of it (for example an XDR type the SDK does not know).reasonis required and should name what is missing. The matrix shows it as not applicable and it is never counted as a pass.
Error classes
| class | meaning | how the runner counts it |
|---|---|---|
rejected | the SDK refused the input through its normal error path: a returned error, a thrown exception, a Result::Err | a rejection. Correct for must-reject inputs |
panic | the SDK crashed instead of rejecting: a Rust panic, a Go panic, a JS RangeError: Maximum call stack size exceeded or any error the testee can tell is not the SDK's documented rejection | always a divergence from the network: core never crashes on these inputs and an SDK should reject, not crash |
bad-request | the request does not follow this protocol (missing member, bad base64, unknown op name) | error: a runner or testee bug, never an SDK finding |
internal | the testee itself failed for a reason unrelated to the input | error |
A testee that cannot tell rejected from panic (because the SDK uses one exception type
for both) reports rejected. Catch panics around every SDK call so one input cannot take the
process down; a process that dies anyway is recorded as error for that request.
Value encodings
These are fixed for every op so that the runner compares like with like.
| value | encoding |
|---|---|
| XDR bytes | standard base64 (RFC 4648 section 4) with = padding, no line breaks |
| raw bytes (strkey payloads) | standard base64 with padding |
| XDR type names | the name in the .x definitions at stellar-xdr v30.0, as stellar-xdr spells it: TransactionEnvelope, LedgerCloseMeta, ScVal, ScSpecFunctionV0 |
| decoded values as JSON | SEP-51 XDR-JSON (Draft, 2.0.1) of the value itself, not wrapped in its type name. Compared as JSON values: member order is irrelevant, everything else must be equal, including 64-bit integers written as strings |
| order | integer -1, 0 or 1: the sign of compare(a, b) |
| 64-bit IDs | decimal string with no sign for unsigned values, a leading - only for negative values, no leading zeros, no whitespace ("0", "16", "-1") |
| liquidity pool ID | lowercase hex of the 32-byte pool hash, 64 characters |
| strkey kind | one of the names in the strkey table below |
hello
Sent first, after every (re)start.
Request: {"id": "0", "op": "hello"}
Result members:
| member | type | meaning |
|---|---|---|
protocol | integer | must be 1 |
sdk | string | the package under test: stellar-xdr (Rust), github.com/stellar/go-stellar-sdk (Go), @stellar/stellar-sdk (JS) |
sdk_version | string | the exact version that is linked or loaded, read from the build or the installed package, never typed in. The runner refuses a testee whose version differs from conformance/versions.toml |
components | object, string to string | other packages whose behaviour the ops depend on and their exact versions, for example {"stellar-strkey": "1.0.0"}. May be empty |
ops | array of strings | the ops this build implements, excluding hello. The runner does not send other ops and records them as unsupported |
decode_limits | "honoured" or "ignored" | whether depth_limit and len_limit reach the SDK's decoder (see decode_encode). "honoured" covers the XDR inputs of every op: asset_order, pool_id, map_sort and map_validate requests carry no limits, so a testee that reports "honoured" decodes their inputs at depth 1000 and with the input's own length as the length limit, or with depth_limit and len_limit when a request carries them |
Operations
decode_encode
Decode bytes as one value of a type and encode the value again.
Request: type (string), xdr (base64), depth_limit (integer), len_limit (integer).
- Decode must consume the whole input. Trailing bytes are a rejection.
depth_limitandlen_limitare the runner's decode limits (Limits { depth, len }in rs-stellar-xdr). A testee passes them to the SDK when the SDK's API accepts limits and reportsdecode_limits: "honoured". Otherwise it decodes with the SDK's defaults and reports"ignored"; the matrix then notes that depth and length entries ran under SDK defaults.- Re-encode with the SDK's encoder and the same limits.
Result: xdr (base64 of the re-encoded bytes).
decode_json
Decode bytes as one value of a type and render it as SEP-51 XDR-JSON.
Request: same members as decode_encode.
Result: json (the SEP-51 value as a JSON value, not a string).
A testee implements this only with the SDK's own decoded objects: either an SDK-native SEP-51 renderer, or a projection the testee writes from the SDK's decoded value (js-xdr type introspection, Go reflection). A second XDR decoder inside the testee defeats the check.
encode_json
Read a SEP-51 XDR-JSON value of a type and encode it as XDR.
Request: type (string), json (JSON value).
Result: xdr (base64).
This op reads JSON where every other op reads bytes. It is needed because the
rs-stellar-xdr advisory GHSA-3xhm-p452-9wjh is a JSON-to-XDR flaw: 28.0.0 accepts a JSON
ScSpecFunctionV0 with two outputs (the XDR allows at most one) and encodes bytes that no
decoder accepts. No decode-side op can see it. SDKs without a SEP-51 reader answer
unsupported.
strkey_decode
Request: text (string, passed to the SDK unchanged, including any whitespace or case
oddities), or instead text_base64 (standard base64 of the input bytes) when the input is not
UTF-8 and so has no JSON string form. Exactly one of the two is present.
A testee passes the text_base64 bytes to the SDK as they are when the SDK's decoder can take
them (a Go string, a Rust &[u8]). An SDK whose decoder takes only Unicode text cannot be
given bytes that are not UTF-8, so its testee answers unsupported for them rather than
picking a lossy mapping to text.
Result: kind (strkey kind) and payload (base64 of the raw payload, defined below).
If the SDK has one decoder per kind rather than a generic one, the testee picks the decoder
from the first character and reports rejected when that decoder refuses the text, and when
no decoder exists for that first character.
strkey_encode
Request: kind (strkey kind), payload (base64 raw payload).
Result: text.
The testee calls the SDK's encoder for that kind with the payload in whatever structured form
the SDK's encoder takes, and must not repair the payload first. An SDK encoder that refuses
the payload is rejected.
Strkey kinds and raw payloads
The raw payload is the byte string between the version byte and the checksum, as SEP-23 defines it. A testee whose SDK returns structured values rebuilds this layout from them.
| kind | prefix | version byte | raw payload |
|---|---|---|---|
account | G | 6 << 3 | 32-byte ed25519 public key |
muxed_account | M | 12 << 3 | 32-byte ed25519 key, then the 8-byte ID big-endian |
seed | S | 18 << 3 | 32-byte ed25519 seed |
pre_auth_tx | T | 19 << 3 | 32-byte transaction hash |
hash_x | X | 23 << 3 | 32-byte hash |
signed_payload | P | 15 << 3 | 32-byte ed25519 key, 4-byte big-endian payload length, the payload, zero padding to a multiple of 4 |
contract | C | 2 << 3 | 32-byte contract ID |
liquidity_pool | L | 11 << 3 | 32-byte pool ID |
claimable_balance | B | 1 << 3 | 1 type byte (0 for v0), then the 32-byte balance ID |
muxed_contract | W | 22 << 3 | 32-byte contract ID, then the 8-byte ID big-endian (CAP-84) |
asset_order
Request: a, b (each base64 XDR of an Asset).
The testee decodes both with the SDK's XDR decoder, turns them into the SDK's asset objects
where the comparison lives on those objects (JS Asset.fromOperation), and calls the SDK's
comparison (Asset.compare in JS, xdr.Asset.LessThan in Go, Ord in Rust).
Result: order (-1, 0 or 1). An SDK that only offers "less than" answers with
lt(a, b) ? -1 : lt(b, a) ? 1 : 0.
pool_id
Request: a, b (base64 XDR Asset), fee (integer, basis points; 30 is the only fee core
accepts).
The assets are passed to the SDK in the given order. An SDK that refuses an unordered or equal
pair answers rejected. An SDK with XDR types but no pool ID function (Rust stellar-xdr)
computes SHA-256 of the LiquidityPoolParameters XDR written by the SDK's own encoder, and
refuses pairs that the SDK's own Asset ordering does not put strictly in order, as core does.
Result: pool_id (lowercase hex).
map_sort
Request: entries, an array of {"key": <base64 ScVal>, "val": <base64 ScVal>} in input
order.
The testee calls the SDK's sorted-map constructor (nativeToScVal/scvSortedMap in JS,
ScMap::sorted_from in Rust).
Result: xdr (base64 of the resulting ScVal, arm SCV_MAP).
map_validate
Request: xdr (base64 of an ScVal whose arm is SCV_MAP with a present map).
Result: valid (boolean): whether the SDK's own map validation accepts the map. An SDK
validation that refuses the map is "valid": false, not an error; rejected is only for
bytes the SDK cannot decode.
id_parse
Request: kind ("muxed" or "offer"), input (a JSON string or a JSON number, written by
the runner exactly as the corpus vector has it, which includes numbers above 2^53 and
negative numbers).
The testee hands input to the SDK API that takes a textual or numeric ID (JS MuxedAccount
and Operation.manageSellOffer; in Rust the only textual path is the XDR-JSON reader of the
id and offer_id fields), in the JSON type it arrived as. A testee must read numbers without going through a binary floating
point type unless that is what the SDK itself does with them. If the SDK has no API for the
input's JSON type, answer unsupported.
Result: id, the 64-bit value the SDK would encode, as a decimal string (unsigned for muxed,
signed for offer).
to_native
Request: xdr (base64 of an ScVal).
The testee calls the SDK's native projection (JS scValToNative) and describes the result
losslessly as a native value:
| native value | form |
|---|---|
| map or dictionary | {"type": "map", "entries": [{"key": <native>, "value": <native>}, ...]} in the order the native structure iterates |
| array | {"type": "array", "items": [<native>, ...]} |
| string | {"type": "string", "value": "..."} |
| byte buffer | {"type": "bytes", "value": "<base64>"} |
| integer of any width | {"type": "integer", "value": "<decimal>"} |
| boolean | {"type": "bool", "value": true} |
| null or undefined | {"type": "null"} |
| anything else | {"type": "object", "class": "<language type name>", "text": "<its string form>"} |
Result: native.
How the runner uses the ops
Per manifest entry kind (crates/shapekit-manifest/src/model.rs). Vector cases
(corpus/vectors/<kind>/<id>.json) carry their op name in op, their inputs, and
expected; the runner turns each into the request below. A case it cannot map is an error
(a runner bug), never skipped.
| entry kind | ops sent | pass when |
|---|---|---|
xdr, decode = must-accept | decode_encode, decode_json, encode_json (the last two only with an expected JSON file, encode_json only with roundtrip = true) | decode_encode is ok and, with roundtrip, returns the input bytes; decode_json equals the variant's expected JSON; encode_json of the expected JSON returns the input bytes |
xdr, decode = must-reject | decode_encode | the answer is rejected |
xdr, decode = implementation-defined | decode_encode, decode_json | every SDK that answers gives the same outcome (same status, same bytes, same JSON) |
ordering, asset_order cases | asset_order | expected less, equal, greater is the order core uses (Asset comparison by type, code, then issuer key bytes) |
ordering, pool_id cases | pool_id | the pool ID equals expected.pool_id, or the answer is rejected when expected.reject is set |
ordering, map_sort cases | map_sort | the result equals expected.map byte for byte, or is rejected when the case expects a rejection |
ordering, map_validate cases | map_validate | valid is true for expected = accept, false for reject |
strkey | strkey_decode, and strkey_encode for valid strings | decode gives the expected kind and raw payload, or rejected for invalid strings; encode of that kind and payload gives back the text |
textual-id | id_parse | the ID equals expected.id, or rejected when the case expects a rejection. A case whose expected is {"either": [{"id": ...}, {"reject": ...}]} passes with exactly that ID or with rejected; any other ID diverges. This covers JSON numbers above 2^53-1 that an SDK may read exactly or refuse, so the case is decided against its own expectation even when the entry is implementation-defined |
native-projection | to_native | rejected, or a native map that keeps every entry of the input map |
Where the expected XDR-JSON contains stellar-xdr's <INVALID: marker (it writes one for a
value with no strkey form, such as a signed-payload signer with an empty payload), SEP-51 has
no canonical rendering of the value. decode_json then passes with any JSON the SDK renders
(it still fails if the SDK refuses), and encode_json is not sent, since there is no
canonical JSON to encode from. decode_encode checks the byte round trip as usual.
Decode requests carry depth_limit 1000 (DECODE_DEPTH, the depth core's Rust parser
applies to envelopes from protocol 28) and len_limit equal to the input length, the limits
the corpus expectations were computed under.
Every xdr variant (testnet, mainnet, any) is checked separately. An entry's result is
the worst of its checks, in this order: error, diverges-from-network,
diverges-from-peers, unsupported, reference, pass (a reference check counts as a pass
for the entry's result, see below). A panic always counts as diverges-from-network.
Because an entry is as bad as its worst check, one op an SDK lacks (Go has no encode_json)
makes the whole entry unsupported. The matrix therefore also counts every check on its own,
per testee (check_summary) and per testee and op (op_summary), and the run prints both the
entry and the check totals.
Implementation-defined checks are decided by one vote per SDK: the SDK's current version in
versions.toml votes when the run holds it, otherwise its last version in the run
(versions.toml order, or the order of --version). An SDK whose voting version does not answer
casts no vote. The most common vote is the majority; every testee, voting or not, whose answer
differs from it is diverges-from-peers, and with a tie no answer is the majority. With fewer
than two votes, as in any run of a single SDK however many versions it holds, there is no peer
to compare with and the check counts as unsupported, not as a pass.
versions.toml may give an SDK version a max_protocol. A divergence on an entry generated
at a later protocol (protocol.generated_at, else protocol.valid_min) is then reported as
unsupported with the original verdict in its detail: an SDK that predates a protocol is not
expected to read it. A panic or a testee process that died is never downgraded this way:
crashing on unknown XDR is a robustness defect, not a missing feature.
The reference testee and the cross-check
Every expected_json in the corpus is the XDR-JSON rendering of the stellar-xdr version the
generator links (generated_with.stellar_xdr in the manifest). The Rust testee at that version
renders with the same library, so its decode_json and encode_json checks agreeing with
expected_json prove nothing beyond the library agreeing with itself. The matrix marks that
testee as the reference for expected_json (reference_for in testees) and counts those
checks as reference (listed in the cell's reference_checks), never as pass. A reference
check that disagrees is still a failure: it means the library cannot read back its own
rendering. In the entry summary, a passing cell with reference checks is counted under
reference.
shapekit conformance crosscheck supplies the independent evidence: it sends every
decode_json check with a canonical expected_json to the Go and JS testees at their
current versions and reports, per entry, where either renders something else. A disagreement
where two decoders render the same other value points at stellar-xdr's rendering; one where a
single decoder differs while another agrees is that SDK's divergence. The command fails unless
every disagreement is a single-SDK divergence already in that SDK's known-failures list.
Known failures
conformance/known-failures/<sdk>.txt lists one known failure per line:
<entry-id> <version or *> <result> <issue link or findings/issues/ draft> <check>[, <check>...]The first four fields are separated by whitespace. Everything after the issue is the list of
failing checks, separated by commas, each named exactly as the matrix names it
(testnet/decode_json, case 3/strkey_decode). Every listed check must fail with <result>;
checks of one entry that fail with different results go on separate lines. * stands for
every version and is only used where every version fails the same checks. A # starts a
comment only at the start of a line or after whitespace, so an issue URL keeps its
#fragment.
Each check is judged on its own. A failing check (diverges-from-network,
diverges-from-peers, error) listed with its result does not fail the run. A failing check
that no line lists fails it, even when other checks of the same entry are listed, so a new
regression next to a known failure is caught. A listed check that now passes, is reference,
is unsupported, fails with another result, or is not a check of the entry fails the run too,
until the line is fixed. Every line must name an entry in the manifest and a version in
versions.toml. A diverges-from-peers check can only be reproduced by a run of more than
one SDK; a single-SDK run does not ask for such a line to be removed.
Limits the runner applies
- Per-request timeout, default 10 s, set per SDK in
conformance/versions.toml. It covers the write of the request and the read of the response. - Response lines of at most 256 MiB.
- A memory limit set with
setrlimitin the child beforeexec:RLIMIT_AS(address space) for testees whose runtime reserves little virtual memory (Rust, Go),RLIMIT_DATAfor runtimes that reserve largePROT_NONEregions up front (Node's V8 cage), where an address-space cap would kill the process before it touches the data. The resource and size are set per SDK inversions.toml.
Writing a testee
- Read requests line by line, answer each with exactly one line, flush after every line.
- Answer
hellowith versions read from the build (Godebug.ReadBuildInfo, the installed package'spackage.json, Cargo's resolved dependency) and never from a constant. - Implement each op against the SDK's public API only. Where the SDK has no API, answer
unsupportedwith a reason. Never emulate an SDK feature inside the testee. - Catch every exception or panic around SDK calls and classify it.
- Add the testee's build and run command per SDK version to
conformance/versions.toml.
The reference testees are testees/rust, testees/go and testees/js.
This page is generated from docs/testee-protocol.md in the repository.