From 68a95ab0d8fcfc0877b1d7d90563c2ea0d3df38d Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:19:50 +0900 Subject: [PATCH 01/14] add settlement IDL --- programs/settlement/idl/cow_settlement.json | 430 ++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 programs/settlement/idl/cow_settlement.json diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json new file mode 100644 index 00000000..e9ca134b --- /dev/null +++ b/programs/settlement/idl/cow_settlement.json @@ -0,0 +1,430 @@ +{ + "address": "MooohhPEAAHwAwEozL7JPEmnDvaahuUpccYN4Yb8ccK", + "metadata": { + "name": "cow_settlement", + "version": "0.1.0", + "spec": "0.1.0", + "description": "CoW Protocol settlement program. HAND-WRITTEN IDL: this program is a native/Pinocchio program, not built with the Anchor framework, so this file was authored manually to describe its wire format as closely as the Anchor IDL grammar allows. See the per-instruction/type docs for spots where the on-chain format cannot be fully expressed (non-standard 1-byte instruction discriminators, and BeginSettle's dynamically-shaped tail)." + }, + "instructions": [ + { + "name": "initialize", + "docs": [ + "Creates the singleton settlement state PDA. Succeeds only once.", + "Non-standard discriminator: this program uses a single instruction-selector byte (see SettlementInstruction in the Rust source), not Anchor's usual 8-byte sighash. The `discriminator` below reflects the real on-chain bytes." + ], + "discriminator": [3], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true, + "docs": [ + "Funds the state PDA's rent and pays the transaction fee." + ] + }, + { + "name": "state_pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [115, 101, 116, 116, 108, 101, 109, 101, 110, 116] + } + ] + }, + "docs": [ + "Canonical PDA seeded by the literal string \"settlement\"." + ] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "create_buffer", + "docs": [ + "Creates one or more per-token buffer PDAs (SPL token accounts) in a single instruction.", + "IDL MODELING NOTE: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). Anchor's IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0). This instruction is solver/internal tooling only, not part of the public client surface, so buffers beyond the first are intentionally left unmodeled here: callers needing more than one buffer per transaction append the extra (buffer_pda, mint) account-meta pairs by hand after building the base instruction.", + "Each buffer_pda_i must be the canonical PDA for seeds [\"settlement\", mint_i, \"buffer\"]." + ], + "discriminator": [4], + "accounts": [ + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "buffer_pda_0", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [115, 101, 116, 116, 108, 101, 109, 101, 110, 116] + }, + { + "kind": "account", + "path": "mint_0" + }, + { + "kind": "const", + "value": [98, 117, 102, 102, 101, 114] + } + ] + }, + "docs": [ + "Guaranteed present: CreateBuffer rejects an instruction with zero buffers." + ] + }, + { + "name": "mint_0", + "docs": [ + "Guaranteed present: CreateBuffer rejects an instruction with zero buffers." + ] + } + ], + "args": [] + }, + { + "name": "create_order", + "docs": [ + "Allocates a per-order PDA and writes the initial OrderAccount body.", + "order_pda's canonical seeds are [\"settlement\", sha256(intent_bytes), \"order\"]. This is not expressible as a static `pda` entry because the middle seed is a hash of the entire `intent` argument, not a plain field/account reference, which is outside what the Anchor PDA-seed grammar (const / arg / account) can describe." + ], + "discriminator": [2], + "accounts": [ + { + "name": "owner", + "signer": true, + "docs": [ + "Must match intent.owner; authenticates the order." + ] + }, + { + "name": "created_by", + "writable": true, + "signer": true, + "docs": [ + "Funds the new order PDA's rent." + ] + }, + { + "name": "order_pda", + "writable": true, + "docs": [ + "See the seed-derivation note above; cannot be auto-derived by IDL-driven tooling." + ] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "intent", + "type": { + "defined": { + "name": "OrderIntent" + } + } + } + ] + }, + { + "name": "begin_settle", + "docs": [ + "Pulls funds for a batch of orders. Must be paired in the same transaction with a FinalizeSettle at `finalize_ix_index`.", + "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index) is represented as a typed argument. After it, the real wire format is `[order_count: u8][bump; order_count][transfer_count; order_count][amount: u64 BE; sum(transfer_count)]` \u2014 a hand-packed layout with no Borsh length prefixes and a trailing array whose length depends on the sum of an earlier array. This has no representation in the Anchor/Borsh/IDL type grammar (no vec/array type can express 'shared count governs several sibling arrays' or 'length = sum of another field'), so it is intentionally left out of `args` rather than mislabeled as `bytes` (which would imply a Borsh Vec length prefix that isn't actually present and would make a generic decoder misparse it). Anchor/Solscan-style tooling will only decode the discriminator and finalize_ix_index for this instruction; the remainder needs bespoke client logic.", + "Per-order accounts follow the 3 shared accounts below as a repeated remaining-accounts group: [order_pda (readonly), sell_token_account (writable), destination (writable) x transfer_count], sorted by ascending order_pda address." + ], + "discriminator": [0], + "accounts": [ + { + "name": "instructions_sysvar", + "address": "Sysvar1nstructions1111111111111111111111111" + }, + { + "name": "state_pda", + "docs": [ + "Must be the canonical state PDA; signs each pull as the user's SPL delegate." + ] + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "args": [ + { + "name": "finalize_ix_index", + "type": "u16", + "docs": [ + "Index of the paired FinalizeSettle in this transaction. Little-endian, matching standard Borsh/Anchor u16 decoding." + ] + } + ] + }, + { + "name": "finalize_settle", + "docs": [ + "Validates that a BeginSettle at `begin_ix_index` exists and points back at this instruction. Must not be called via CPI.", + "Non-standard discriminator, see the note on `initialize`." + ], + "discriminator": [1], + "accounts": [ + { + "name": "instructions_sysvar", + "address": "Sysvar1nstructions1111111111111111111111111" + } + ], + "args": [ + { + "name": "begin_ix_index", + "type": "u16", + "docs": [ + "Index of the paired BeginSettle in this transaction. Little-endian, matching standard Borsh/Anchor u16 decoding." + ] + } + ] + } + ], + "accounts": [ + { + "name": "OrderAccount", + "discriminator": [128] + }, + { + "name": "SettlementState", + "discriminator": [129] + } + ], + "types": [ + { + "name": "OrderAccount", + "docs": [ + "Body of an order PDA, created by create_order. 200 bytes total: 1-byte discriminator + 199 bytes of fields below." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "cancelled", + "type": "bool" + }, + { + "name": "amount_withdrawn", + "type": "u64" + }, + { + "name": "amount_received", + "type": "u64" + }, + { + "name": "created_by", + "type": "pubkey" + }, + { + "name": "intent", + "type": { + "defined": { + "name": "OrderIntent" + } + } + } + ] + } + }, + { + "name": "SettlementState", + "docs": [ + "Body of the singleton settlement state PDA. Carries no fields beyond the 1-byte discriminator; its existence and address are what matter." + ], + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "OrderIntent", + "docs": [ + "Canonical 150-byte order intent. Also the exact bytes hashed (SHA-256) to produce the order UID used in the order PDA's seeds, and the exact wire format of create_order's `intent` argument. Field order and encoding here are load-bearing: they must match this program's Rust definition exactly." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "buy_token_account", + "type": "pubkey" + }, + { + "name": "sell_token_account", + "type": "pubkey" + }, + { + "name": "sell_amount", + "type": "u64" + }, + { + "name": "buy_amount", + "type": "u64" + }, + { + "name": "valid_to", + "type": "u32" + }, + { + "name": "kind", + "type": { + "defined": { + "name": "OrderKind" + } + } + }, + { + "name": "partially_fillable", + "type": "bool" + }, + { + "name": "app_data", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "OrderKind", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Sell" + }, + { + "name": "Buy" + } + ] + } + } + ], + "errors": [ + { + "code": 0, + "name": "FinalizeBeforeInitialize", + "msg": "The FinalizeSettle included as input to BeginSettle isn't before the actual BeginSettle index." + }, + { + "code": 1, + "name": "BeginFinalizePairOverlap", + "msg": "Another BeginSettle/FinalizeSettle of this program appears strictly between this pair's bounds, nesting or overlapping two settlements." + }, + { + "code": 2, + "name": "MissingCounterpartInstruction", + "msg": "The counterpart index points past the end of the transaction's instruction list, so no instruction sits there." + }, + { + "code": 3, + "name": "CounterpartIsExternal", + "msg": "The instruction at the counterpart index belongs to a different program." + }, + { + "code": 4, + "name": "InvalidCounterpartDiscriminator", + "msg": "The counterpart instruction's discriminator byte couldn't be recovered from its data." + }, + { + "code": 5, + "name": "InvalidCounterpartCounterpart", + "msg": "The counterpart instruction's own counterpart index couldn't be recovered from its data." + }, + { + "code": 6, + "name": "MismatchedCounterpartDiscriminator", + "msg": "The counterpart's discriminator isn't the expected BeginSettle/FinalizeSettle kind, or its counterpart index doesn't point back at this instruction." + }, + { + "code": 7, + "name": "OwnerMismatch", + "msg": "CreateOrder instruction wasn't signed by the created OrderIntent owner." + }, + { + "code": 8, + "name": "OrderNotCanonical", + "msg": "A BeginSettle order account doesn't sit at the canonical order PDA derived from the intent it stores and the supplied bump." + }, + { + "code": 9, + "name": "OrdersNotStrictlyIncreasing", + "msg": "BeginSettle's order accounts aren't passed strictly increasing by address." + }, + { + "code": 10, + "name": "SellTokenAccountMismatch", + "msg": "A BeginSettle sell token account doesn't match the sell_token_account recorded in the order's intent." + }, + { + "code": 11, + "name": "SellTokenAccountInvalid", + "msg": "A BeginSettle sell token account isn't a valid SPL token account (wrong data length or not owned by the token program)." + }, + { + "code": 12, + "name": "SellTokenOwnerMismatch", + "msg": "A BeginSettle sell token account's SPL owner isn't the order's intent owner." + }, + { + "code": 13, + "name": "AccountCountNotMatchingOrderCount", + "msg": "BeginSettle's order-account count doesn't match the structure its instruction data expects." + }, + { + "code": 14, + "name": "CalledViaCpi", + "msg": "BeginSettle or FinalizeSettle was invoked via CPI rather than as a top-level transaction instruction." + }, + { + "code": 15, + "name": "OrderCancelled", + "msg": "A BeginSettle order has been cancelled by its owner and can no longer be settled." + }, + { + "code": 16, + "name": "OrderExpired", + "msg": "A BeginSettle order's valid_to lies in the past: the order has expired and can no longer be settled." + }, + { + "code": 17, + "name": "TransferCountMismatch", + "msg": "The transfer counts in BeginSettle don't sum to the number of transfer amounts, so destinations and amounts can't be paired up exactly." + }, + { + "code": 18, + "name": "StateAccountMismatch", + "msg": "BeginSettle's state account isn't the canonical settlement state PDA, which must sign the pulls as the user's token delegate." + } + ] +} From 7e63017a7d3ec15cd0a6b70cba01c57c60e92d36 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:32:03 +0900 Subject: [PATCH 02/14] add validation of the idl --- Cargo.lock | 209 ++++- Cargo.toml | 3 + interface/src/data/intent.rs | 3 + programs/settlement/Cargo.toml | 3 + programs/settlement/idl/cow_settlement.json | 170 +++- .../idl/schema/idl-spec-v0.1.0.json | 780 ++++++++++++++++++ programs/settlement/tests/idl.rs | 364 ++++++++ 7 files changed, 1510 insertions(+), 22 deletions(-) create mode 100644 programs/settlement/idl/schema/idl-spec-v0.1.0.json create mode 100644 programs/settlement/tests/idl.rs diff --git a/Cargo.lock b/Cargo.lock index 29205872..f408f888 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -620,6 +621,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.7.0" @@ -690,6 +697,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1223,6 +1236,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1285,6 +1307,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1353,6 +1386,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1368,6 +1412,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num 0.4.3", +] + [[package]] name = "futures" version = "0.3.32" @@ -1919,6 +1973,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "reqwest", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "k256" version = "0.13.4" @@ -2198,10 +2279,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ "num-bigint 0.2.6", - "num-complex", + "num-complex 0.2.4", "num-integer", "num-iter", - "num-rational", + "num-rational 0.2.4", + "num-traits", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex 0.4.6", + "num-integer", + "num-iter", + "num-rational 0.4.2", "num-traits", ] @@ -2226,6 +2321,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.2.4" @@ -2236,6 +2337,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -2279,6 +2389,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2328,6 +2449,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2384,7 +2511,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -2737,6 +2864,40 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.4" @@ -3019,6 +3180,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -3065,12 +3227,14 @@ name = "settlement" version = "0.1.0" dependencies = [ "arrayref", + "jsonschema", "litesvm", "litesvm-token", "pinocchio", "pinocchio-system", "pinocchio-token", "proptest", + "serde_json", "settlement-client", "settlement-interface", "solana-address-lookup-table-interface", @@ -3078,6 +3242,7 @@ dependencies = [ "solana-instructions-sysvar", "solana-sdk", "solana-system-interface 3.2.0", + "syn 2.0.118", ] [[package]] @@ -5367,6 +5532,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5717,6 +5893,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5729,6 +5926,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 2e4f19ca..6abbb802 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ solana-program-pack = "3" solana-rpc-client = "3" derive_more = { version = "1", features = ["deref"] } hex-literal = "1" +jsonschema = "0.30" litesvm = "0.12.0" litesvm-token = "0.12.0" num_enum = "0.7" @@ -26,6 +27,7 @@ pinocchio = "0.11.1" pinocchio-system = "0.6" pinocchio-token = "0.6" proptest = "1" +serde_json = { version = "1", features = ["preserve_order"] } settlement-client = { path = "client" } settlement-interface = { path = "interface" } solana-account-view = "2" @@ -43,6 +45,7 @@ solana-system-interface = "3" spl-associated-token-account-interface = { version = "2" } spl-token = "9" spl-token-interface = "2" +syn = { version = "2", features = ["full"] } [workspace.metadata.cli] # Used to help solana-verify identify the correct build image. diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index ba1ac0fa..8586b955 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -32,6 +32,9 @@ pub enum OrderKind { Buy = 1, } +/// Canonical order intent. Also the exact bytes hashed (SHA-256) to produce the order UID used in the order PDA's seeds, +/// and the exact wire format of create_order's `intent` argument. Field order and encoding here are load-bearing: they +/// must match this program's Rust definition exactly. #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct OrderIntent { /// Account authorized to create and invalidate this order and whose diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 028e2c49..e1b20417 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -21,15 +21,18 @@ solana-instruction = { workspace = true, features = ["syscalls"] } [dev-dependencies] arrayref.workspace = true +jsonschema.workspace = true litesvm.workspace = true litesvm-token.workspace = true proptest.workspace = true +serde_json.workspace = true settlement-client.workspace = true settlement-interface = { workspace = true, features = ["test-fixtures"] } solana-address-lookup-table-interface = { workspace = true, features = ["bincode"] } solana-instructions-sysvar.workspace = true solana-sdk.workspace = true solana-system-interface.workspace = true +syn.workspace = true [lints] workspace = true diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index e9ca134b..3c2415a9 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -13,7 +13,9 @@ "Creates the singleton settlement state PDA. Succeeds only once.", "Non-standard discriminator: this program uses a single instruction-selector byte (see SettlementInstruction in the Rust source), not Anchor's usual 8-byte sighash. The `discriminator` below reflects the real on-chain bytes." ], - "discriminator": [3], + "discriminator": [ + 3 + ], "accounts": [ { "name": "payer", @@ -30,7 +32,18 @@ "seeds": [ { "kind": "const", - "value": [115, 101, 116, 116, 108, 101, 109, 101, 110, 116] + "value": [ + 115, + 101, + 116, + 116, + 108, + 101, + 109, + 101, + 110, + 116 + ] } ] }, @@ -52,7 +65,9 @@ "IDL MODELING NOTE: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). Anchor's IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0). This instruction is solver/internal tooling only, not part of the public client surface, so buffers beyond the first are intentionally left unmodeled here: callers needing more than one buffer per transaction append the extra (buffer_pda, mint) account-meta pairs by hand after building the base instruction.", "Each buffer_pda_i must be the canonical PDA for seeds [\"settlement\", mint_i, \"buffer\"]." ], - "discriminator": [4], + "discriminator": [ + 4 + ], "accounts": [ { "name": "payer", @@ -74,7 +89,18 @@ "seeds": [ { "kind": "const", - "value": [115, 101, 116, 116, 108, 101, 109, 101, 110, 116] + "value": [ + 115, + 101, + 116, + 116, + 108, + 101, + 109, + 101, + 110, + 116 + ] }, { "kind": "account", @@ -82,7 +108,14 @@ }, { "kind": "const", - "value": [98, 117, 102, 102, 101, 114] + "value": [ + 98, + 117, + 102, + 102, + 101, + 114 + ] } ] }, @@ -105,7 +138,9 @@ "Allocates a per-order PDA and writes the initial OrderAccount body.", "order_pda's canonical seeds are [\"settlement\", sha256(intent_bytes), \"order\"]. This is not expressible as a static `pda` entry because the middle seed is a hash of the entire `intent` argument, not a plain field/account reference, which is outside what the Anchor PDA-seed grammar (const / arg / account) can describe." ], - "discriminator": [2], + "discriminator": [ + 2 + ], "accounts": [ { "name": "owner", @@ -149,10 +184,12 @@ "name": "begin_settle", "docs": [ "Pulls funds for a batch of orders. Must be paired in the same transaction with a FinalizeSettle at `finalize_ix_index`.", - "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index) is represented as a typed argument. After it, the real wire format is `[order_count: u8][bump; order_count][transfer_count; order_count][amount: u64 BE; sum(transfer_count)]` \u2014 a hand-packed layout with no Borsh length prefixes and a trailing array whose length depends on the sum of an earlier array. This has no representation in the Anchor/Borsh/IDL type grammar (no vec/array type can express 'shared count governs several sibling arrays' or 'length = sum of another field'), so it is intentionally left out of `args` rather than mislabeled as `bytes` (which would imply a Borsh Vec length prefix that isn't actually present and would make a generic decoder misparse it). Anchor/Solscan-style tooling will only decode the discriminator and finalize_ix_index for this instruction; the remainder needs bespoke client logic.", + "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index, auction_id) is represented as typed arguments. After it, the real wire format is `[order_count: u8][bump; order_count][transfer_count; order_count][amount: u64 LE; sum(transfer_count)]` — a hand-packed layout with no Borsh length prefixes and a trailing array whose length depends on the sum of an earlier array. This has no representation in the Anchor/Borsh/IDL type grammar (no vec/array type can express 'shared count governs several sibling arrays' or 'length = sum of another field'), so it is intentionally left out of `args` rather than mislabeled as `bytes` (which would imply a Borsh Vec length prefix that isn't actually present and would make a generic decoder misparse it). Anchor/Solscan-style tooling will only decode the discriminator, finalize_ix_index, and auction_id for this instruction; the remainder needs bespoke client logic.", "Per-order accounts follow the 3 shared accounts below as a repeated remaining-accounts group: [order_pda (readonly), sell_token_account (writable), destination (writable) x transfer_count], sorted by ascending order_pda address." ], - "discriminator": [0], + "discriminator": [ + 0 + ], "accounts": [ { "name": "instructions_sysvar", @@ -176,6 +213,13 @@ "docs": [ "Index of the paired FinalizeSettle in this transaction. Little-endian, matching standard Borsh/Anchor u16 decoding." ] + }, + { + "name": "auction_id", + "type": "i64", + "docs": [ + "The off-chain auction this settlement executes. Carried in the instruction data so the settlement can be tied back to its auction off-chain, unused on-chain." + ] } ] }, @@ -183,13 +227,26 @@ "name": "finalize_settle", "docs": [ "Validates that a BeginSettle at `begin_ix_index` exists and points back at this instruction. Must not be called via CPI.", - "Non-standard discriminator, see the note on `initialize`." + "Non-standard discriminator, see the note on `initialize`.", + "Per-push accounts follow the 3 shared accounts below as a repeated remaining-accounts group: [source_buffer (writable), destination (writable)] x number of pushes." + ], + "discriminator": [ + 1 ], - "discriminator": [1], "accounts": [ { "name": "instructions_sysvar", "address": "Sysvar1nstructions1111111111111111111111111" + }, + { + "name": "state_pda", + "docs": [ + "Must be the canonical state PDA; signs each push as the buffers' SPL token authority." + ] + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } ], "args": [ @@ -201,23 +258,62 @@ ] } ] + }, + { + "name": "reclaim_order", + "docs": [ + "Closes an expired order PDA and returns its rent lamports to the created_by account recorded in the order body. The instruction may only be executed after the order's valid_to timestamp has elapsed.", + "No signature requirement: anyone may reclaim an expired order on behalf of its reclaim_recipient." + ], + "discriminator": [ + 5 + ], + "accounts": [ + { + "name": "order_pda", + "writable": true, + "docs": [ + "The order PDA to close." + ] + }, + { + "name": "reclaim_recipient", + "writable": true, + "docs": [ + "Must match the created_by address recorded in the order; receives the recovered rent lamports." + ] + } + ], + "args": [ + { + "name": "bump", + "type": "u8", + "docs": [ + "order_pda's canonical bump, used to prove it's the canonical order PDA for the intent it stores." + ] + } + ] } ], "accounts": [ { "name": "OrderAccount", - "discriminator": [128] + "discriminator": [ + 128 + ] }, { "name": "SettlementState", - "discriminator": [129] + "discriminator": [ + 129 + ] } ], "types": [ { "name": "OrderAccount", "docs": [ - "Body of an order PDA, created by create_order. 200 bytes total: 1-byte discriminator + 199 bytes of fields below." + "Idiomatic representation of an order PDA's body." ], "type": { "kind": "struct", @@ -255,14 +351,15 @@ "Body of the singleton settlement state PDA. Carries no fields beyond the 1-byte discriminator; its existence and address are what matter." ], "type": { - "kind": "struct", - "fields": [] + "kind": "struct" } }, { "name": "OrderIntent", "docs": [ - "Canonical 150-byte order intent. Also the exact bytes hashed (SHA-256) to produce the order UID used in the order PDA's seeds, and the exact wire format of create_order's `intent` argument. Field order and encoding here are load-bearing: they must match this program's Rust definition exactly." + "Canonical order intent. Also the exact bytes hashed (SHA-256) to produce the order UID used in the order PDA's seeds,", + "and the exact wire format of create_order's `intent` argument. Field order and encoding here are load-bearing: they", + "must match this program's Rust definition exactly." ], "type": { "kind": "struct", @@ -373,8 +470,8 @@ }, { "code": 8, - "name": "OrderNotCanonical", - "msg": "A BeginSettle order account doesn't sit at the canonical order PDA derived from the intent it stores and the supplied bump." + "name": "AccountNotDerivable", + "msg": "An account was provided that cannot be derived from the seeds recognized by the program" }, { "code": 9, @@ -399,7 +496,7 @@ { "code": 13, "name": "AccountCountNotMatchingOrderCount", - "msg": "BeginSettle's order-account count doesn't match the structure its instruction data expects." + "msg": "BeginSettle's order-account count doesn't match the structure its instruction data expects: n orders each contribute an order PDA and a sell token account, plus one destination account per transfer." }, { "code": 14, @@ -425,6 +522,41 @@ "code": 18, "name": "StateAccountMismatch", "msg": "BeginSettle's state account isn't the canonical settlement state PDA, which must sign the pulls as the user's token delegate." + }, + { + "code": 19, + "name": "AccountCountNotMatchingPushCount", + "msg": "FinalizeSettle's push-account count doesn't match its instruction data: each push contributes a source buffer and a destination account, so the count must be twice the number of push amounts." + }, + { + "code": 20, + "name": "SettledOrderPushCountMismatch", + "msg": "BeginSettle: the number of pushes carried by the paired FinalizeSettle doesn't equal the number of settled orders. Each order must be paid by exactly one push." + }, + { + "code": 21, + "name": "PushDestinationMismatch", + "msg": "BeginSettle: a paired FinalizeSettle push doesn't send its proceeds to the order's buy token account; its destination differs from the buy_token_account in the order's intent." + }, + { + "code": 22, + "name": "PushSourceNotBuffer", + "msg": "FinalizeSettle: a push doesn't draw funds from the canonical buffer for its destination's mint." + }, + { + "code": 23, + "name": "InvalidBuyTokenAccount", + "msg": "FinalizeSettle: a push's destination isn't a valid SPL token account (wrong data length or not owned by the token program), so its mint can't be read to derive the buffer." + }, + { + "code": 24, + "name": "OrderNotExpired", + "msg": "ReclaimOrder was called before the order's valid_to has elapsed." + }, + { + "code": 25, + "name": "ReclaimRecipientMismatch", + "msg": "ReclaimOrder's reclaim_recipient account doesn't match the created_by address recorded in the order." } ] } diff --git a/programs/settlement/idl/schema/idl-spec-v0.1.0.json b/programs/settlement/idl/schema/idl-spec-v0.1.0.json new file mode 100644 index 00000000..e2564ff9 --- /dev/null +++ b/programs/settlement/idl/schema/idl-spec-v0.1.0.json @@ -0,0 +1,780 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/solana-idl/idl-spec/schema/v0.1.0.json", + "title": "Solana IDL v0.1.0", + "description": "JSON Schema for Solana program Interface Description Language (IDL) files, spec version 0.1.0.", + "type": "object", + "required": ["address", "metadata", "instructions"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional JSON Schema reference for editor support." + }, + "address": { + "type": "string", + "description": "The on-chain address of the deployed program." + }, + "metadata": { + "$ref": "#/$defs/IdlMetadata" + }, + "docs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Top-level documentation strings for the program. Omitted when empty." + }, + "instructions": { + "type": "array", + "items": { "$ref": "#/$defs/IdlInstruction" }, + "description": "All instructions the program exposes." + }, + "accounts": { + "type": "array", + "items": { "$ref": "#/$defs/IdlAccount" }, + "default": [], + "description": "Account types the program defines (with discriminators). Omitted when empty." + }, + "events": { + "type": "array", + "items": { "$ref": "#/$defs/IdlEvent" }, + "default": [], + "description": "Events the program can emit. Omitted when empty." + }, + "errors": { + "type": "array", + "items": { "$ref": "#/$defs/IdlErrorCode" }, + "default": [], + "description": "Custom error codes. Omitted when empty." + }, + "types": { + "type": "array", + "items": { "$ref": "#/$defs/IdlTypeDef" }, + "default": [], + "description": "Custom type definitions (structs, enums, type aliases). Omitted when empty." + }, + "constants": { + "type": "array", + "items": { "$ref": "#/$defs/IdlConst" }, + "default": [], + "description": "Exported constants. Omitted when empty." + } + }, + "$defs": { + "IdlMetadata": { + "type": "object", + "required": ["name", "version", "spec"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Program name (typically snake_case)." + }, + "version": { + "type": "string", + "description": "Program version (semver)." + }, + "spec": { + "type": "string", + "const": "0.1.0", + "description": "IDL spec version. Must be \"0.1.0\" for this spec." + }, + "description": { + "type": "string", + "description": "Human-readable program description. Omitted when absent." + }, + "repository": { + "type": "string", + "description": "URL of the source repository. Omitted when absent." + }, + "dependencies": { + "type": "array", + "items": { "$ref": "#/$defs/IdlDependency" }, + "default": [], + "description": "External program dependencies. Omitted when empty." + }, + "contact": { + "type": "string", + "description": "Contact information for the program maintainer. Omitted when absent." + }, + "deployments": { + "$ref": "#/$defs/IdlDeployments", + "description": "Deployment addresses per cluster. Omitted when absent." + } + } + }, + "IdlDependency": { + "type": "object", + "required": ["name", "version"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Dependency name." + }, + "version": { + "type": "string", + "description": "Dependency version (semver)." + } + } + }, + "IdlDeployments": { + "type": "object", + "required": ["mainnet", "testnet", "devnet", "localnet"], + "additionalProperties": false, + "properties": { + "mainnet": { + "type": ["string", "null"], + "description": "Mainnet-beta program address, or null when absent." + }, + "testnet": { + "type": ["string", "null"], + "description": "Testnet program address, or null when absent." + }, + "devnet": { + "type": ["string", "null"], + "description": "Devnet program address, or null when absent." + }, + "localnet": { + "type": ["string", "null"], + "description": "Localnet program address, or null when absent." + } + } + }, + "IdlDiscriminator": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "description": "Variable-length byte array uniquely identifying instructions, accounts, or events. Length and derivation are framework-specific." + }, + "IdlInstruction": { + "type": "object", + "required": ["name", "discriminator", "accounts", "args"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Instruction name (camelCase)." + }, + "docs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Documentation strings. Omitted when empty." + }, + "discriminator": { + "$ref": "#/$defs/IdlDiscriminator" + }, + "accounts": { + "type": "array", + "items": { "$ref": "#/$defs/IdlInstructionAccountItem" }, + "description": "Accounts required by this instruction." + }, + "args": { + "type": "array", + "items": { "$ref": "#/$defs/IdlField" }, + "description": "Instruction arguments." + }, + "returns": { + "$ref": "#/$defs/IdlType", + "description": "Return type (if the instruction returns data via return_data). Omitted when absent." + } + } + }, + "IdlInstructionAccountItem": { + "description": "Untagged union: either a single account or a composite (nested group). Distinguished by structure: a Composite has an 'accounts' array, a Single does not.", + "oneOf": [ + { "$ref": "#/$defs/IdlInstructionAccounts" }, + { "$ref": "#/$defs/IdlInstructionAccount" } + ] + }, + "IdlInstructionAccount": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Account name (camelCase)." + }, + "docs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Documentation strings. Omitted when empty." + }, + "writable": { + "type": "boolean", + "default": false, + "description": "Whether the account is written to. Omitted when false." + }, + "signer": { + "type": "boolean", + "default": false, + "description": "Whether the account must sign the transaction. Omitted when false." + }, + "optional": { + "type": "boolean", + "default": false, + "description": "Whether the account is optional. Omitted when false." + }, + "address": { + "type": "string", + "description": "Expected fixed address (e.g. a well-known program). Omitted when absent." + }, + "pda": { + "$ref": "#/$defs/IdlPda", + "description": "PDA derivation info for this account. Omitted when absent." + }, + "relations": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Names of related accounts (constraint references). Omitted when empty." + } + } + }, + "IdlInstructionAccounts": { + "type": "object", + "required": ["name", "accounts"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Group name." + }, + "accounts": { + "type": "array", + "items": { "$ref": "#/$defs/IdlInstructionAccountItem" }, + "description": "Nested account items." + } + } + }, + "IdlField": { + "type": "object", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Field name (camelCase)." + }, + "docs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Documentation strings. Omitted when empty." + }, + "type": { + "$ref": "#/$defs/IdlType", + "description": "The field's type." + } + } + }, + "IdlAccount": { + "type": "object", + "required": ["name", "discriminator"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Account type name (PascalCase)." + }, + "discriminator": { + "$ref": "#/$defs/IdlDiscriminator" + } + } + }, + "IdlEvent": { + "type": "object", + "required": ["name", "discriminator"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Event name (PascalCase)." + }, + "discriminator": { + "$ref": "#/$defs/IdlDiscriminator" + } + } + }, + "IdlErrorCode": { + "type": "object", + "required": ["code", "name"], + "additionalProperties": false, + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "description": "Numeric error code. Custom errors typically start at 6000." + }, + "name": { + "type": "string", + "description": "Error name (PascalCase)." + }, + "msg": { + "type": "string", + "description": "Human-readable error message. Omitted when absent." + } + } + }, + "IdlTypeDef": { + "type": "object", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Type name (PascalCase)." + }, + "docs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Documentation strings. Omitted when empty." + }, + "serialization": { + "$ref": "#/$defs/IdlSerialization", + "description": "Serialization format. Omitted when \"borsh\" (default)." + }, + "repr": { + "$ref": "#/$defs/IdlRepr", + "description": "Memory representation hint. Omitted when absent." + }, + "generics": { + "type": "array", + "items": { "$ref": "#/$defs/IdlTypeDefGeneric" }, + "default": [], + "description": "Generic parameters. Omitted when empty." + }, + "type": { + "$ref": "#/$defs/IdlTypeDefTy", + "description": "The kind of type (struct, enum, or alias)." + } + } + }, + "IdlTypeDefTy": { + "description": "Tagged union describing the shape of a type definition. Uses 'kind' as the tag field.", + "oneOf": [ + { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "const": "struct" }, + "fields": { + "$ref": "#/$defs/IdlDefinedFields", + "description": "Struct fields. Omitted when absent." + } + } + }, + { + "type": "object", + "required": ["kind", "variants"], + "additionalProperties": false, + "properties": { + "kind": { "const": "enum" }, + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/IdlEnumVariant" }, + "description": "Enum variants." + } + } + }, + { + "type": "object", + "required": ["kind", "alias"], + "additionalProperties": false, + "properties": { + "kind": { "const": "type" }, + "alias": { + "$ref": "#/$defs/IdlType", + "description": "The aliased type." + } + } + } + ] + }, + "IdlEnumVariant": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Variant name (PascalCase)." + }, + "fields": { + "$ref": "#/$defs/IdlDefinedFields", + "description": "Variant data, if any. Omitted when absent." + } + } + }, + "IdlDefinedFields": { + "description": "Untagged union: Named fields (array of objects with 'name' and 'type') or Tuple fields (array of IdlType values).", + "oneOf": [ + { + "type": "array", + "items": { "$ref": "#/$defs/IdlField" }, + "description": "Named fields (like a Rust struct)." + }, + { + "type": "array", + "items": { "$ref": "#/$defs/IdlType" }, + "description": "Positional fields (like a Rust tuple struct)." + } + ] + }, + "IdlType": { + "description": "The IDL type system covering primitives, containers, defined types, and generics.", + "oneOf": [ + { + "type": "string", + "enum": [ + "bool", + "u8", "i8", + "u16", "i16", + "u32", "i32", + "f32", + "u64", "i64", + "f64", + "u128", "i128", + "u256", "i256", + "bytes", "string", "pubkey" + ], + "description": "Primitive type." + }, + { + "type": "object", + "required": ["option"], + "additionalProperties": false, + "properties": { + "option": { "$ref": "#/$defs/IdlType" } + }, + "description": "Optional value." + }, + { + "type": "object", + "required": ["vec"], + "additionalProperties": false, + "properties": { + "vec": { "$ref": "#/$defs/IdlType" } + }, + "description": "Variable-length list." + }, + { + "type": "object", + "required": ["array"], + "additionalProperties": false, + "properties": { + "array": { + "type": "array", + "items": [ + { "$ref": "#/$defs/IdlType" }, + { "$ref": "#/$defs/IdlArrayLen" } + ], + "additionalItems": false, + "minItems": 2, + "maxItems": 2 + } + }, + "description": "Fixed-length array: [type, length]." + }, + { + "type": "object", + "required": ["defined"], + "additionalProperties": false, + "properties": { + "defined": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Name of the user-defined type from the types array." + }, + "generics": { + "type": "array", + "items": { "$ref": "#/$defs/IdlGenericArg" }, + "default": [], + "description": "Generic arguments. Omitted when empty." + } + } + } + }, + "description": "Reference to a user-defined type." + }, + { + "type": "object", + "required": ["generic"], + "additionalProperties": false, + "properties": { + "generic": { + "type": "string", + "description": "Name of the generic type parameter." + } + }, + "description": "A generic type parameter." + } + ] + }, + "IdlArrayLen": { + "description": "Array length: a bare number for fixed sizes, or an object for generic const parameters.", + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "description": "Fixed numeric length." + }, + { + "type": "object", + "required": ["generic"], + "additionalProperties": false, + "properties": { + "generic": { + "type": "string", + "description": "Generic const parameter name." + } + }, + "description": "Generic const parameter length." + } + ] + }, + "IdlGenericArg": { + "description": "Generic argument when instantiating a generic type. Tagged with 'kind'.", + "oneOf": [ + { + "type": "object", + "required": ["kind", "type"], + "additionalProperties": false, + "properties": { + "kind": { "const": "type" }, + "type": { "$ref": "#/$defs/IdlType" } + }, + "description": "A type argument." + }, + { + "type": "object", + "required": ["kind", "value"], + "additionalProperties": false, + "properties": { + "kind": { "const": "const" }, + "value": { + "type": "string", + "description": "Const value as a string." + } + }, + "description": "A const argument." + } + ] + }, + "IdlTypeDefGeneric": { + "description": "Declares a generic parameter on a type definition. Tagged with 'kind'.", + "oneOf": [ + { + "type": "object", + "required": ["kind", "name"], + "additionalProperties": false, + "properties": { + "kind": { "const": "type" }, + "name": { + "type": "string", + "description": "Generic type parameter name." + } + }, + "description": "A generic type parameter." + }, + { + "type": "object", + "required": ["kind", "name", "type"], + "additionalProperties": false, + "properties": { + "kind": { "const": "const" }, + "name": { + "type": "string", + "description": "Const generic parameter name." + }, + "type": { + "type": "string", + "description": "The const generic's type (e.g. \"usize\")." + } + }, + "description": "A const generic parameter." + } + ] + }, + "IdlSerialization": { + "description": "Serialization format. Default is \"borsh\" (omitted from output).", + "oneOf": [ + { + "type": "string", + "enum": ["borsh", "bytemuck", "bytemuckunsafe"], + "description": "Built-in serialization format." + }, + { + "type": "object", + "required": ["custom"], + "additionalProperties": false, + "properties": { + "custom": { + "type": "string", + "description": "Custom serialization format name." + } + }, + "description": "A custom serialization format." + } + ] + }, + "IdlRepr": { + "description": "Memory representation hint, mirroring Rust's #[repr(...)]. Tagged with 'kind'.", + "oneOf": [ + { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "const": "rust" }, + "packed": { + "type": "boolean", + "default": false, + "description": "Whether the type uses packed layout. Omitted when false." + }, + "align": { + "type": "integer", + "minimum": 1, + "description": "Explicit alignment in bytes. Omitted when absent." + } + }, + "description": "Default Rust representation." + }, + { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "const": "c" }, + "packed": { + "type": "boolean", + "default": false, + "description": "Whether the type uses packed layout. Omitted when false." + }, + "align": { + "type": "integer", + "minimum": 1, + "description": "Explicit alignment in bytes. Omitted when absent." + } + }, + "description": "C-compatible representation." + }, + { + "type": "object", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { "const": "transparent" } + }, + "description": "Transparent representation (single-field wrapper)." + } + ] + }, + "IdlConst": { + "type": "object", + "required": ["name", "type", "value"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Constant name (SCREAMING_SNAKE_CASE)." + }, + "docs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Documentation strings. Omitted when empty." + }, + "type": { + "$ref": "#/$defs/IdlType", + "description": "The constant's type." + }, + "value": { + "type": "string", + "description": "The constant's value as a string representation." + } + } + }, + "IdlPda": { + "type": "object", + "required": ["seeds"], + "additionalProperties": false, + "properties": { + "seeds": { + "type": "array", + "items": { "$ref": "#/$defs/IdlSeed" }, + "description": "Ordered list of seeds." + }, + "program": { + "$ref": "#/$defs/IdlSeed", + "description": "The program to derive against (defaults to the current program). Omitted when absent." + } + } + }, + "IdlSeed": { + "description": "Tagged union for PDA seed sources. Uses 'kind' as the tag field.", + "oneOf": [ + { + "type": "object", + "required": ["kind", "value"], + "additionalProperties": false, + "properties": { + "kind": { "const": "const" }, + "value": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "description": "Raw bytes of the constant seed." + } + }, + "description": "A constant byte value." + }, + { + "type": "object", + "required": ["kind", "path"], + "additionalProperties": false, + "properties": { + "kind": { "const": "arg" }, + "path": { + "type": "string", + "description": "Dot-delimited path to the instruction argument." + } + }, + "description": "A value from an instruction argument." + }, + { + "type": "object", + "required": ["kind", "path"], + "additionalProperties": false, + "properties": { + "kind": { "const": "account" }, + "path": { + "type": "string", + "description": "Dot-delimited path to the account field." + }, + "account": { + "type": "string", + "description": "The account type name (if resolving a field from a deserialized account). Omitted when absent." + } + }, + "description": "A value from an account field." + } + ] + } + } +} diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs new file mode 100644 index 00000000..2ec5aed6 --- /dev/null +++ b/programs/settlement/tests/idl.rs @@ -0,0 +1,364 @@ +//! IDL correctness tests for `programs/settlement/idl/cow_settlement.json`. +//! +//! This program is a native Pinocchio program with a hand-written IDL (no +//! `anchor idl build`/shank step keeps it in sync), so these tests +//! cross-check the checked-in file against the Rust source it describes +//! instead of trusting it as-is. +//! +//! The vendored schema (`idl/schema/idl-spec-v0.1.0.json`) is a snapshot of +//! ; +//! re-fetch it there if the spec version ever bumps. + +mod common; + +use std::collections::BTreeSet; + +use serde_json::Value; +use settlement_interface::{ + pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, + SettlementAccount, SettlementInstruction, +}; + +const IDL_JSON: &str = include_str!("../idl/cow_settlement.json"); +const IDL_SCHEMA_JSON: &str = include_str!("../idl/schema/idl-spec-v0.1.0.json"); +const INTERFACE_LIB_RS: &str = include_str!("../../../interface/src/lib.rs"); +const INTENT_RS: &str = include_str!("../../../interface/src/data/intent.rs"); +const ORDER_RS: &str = include_str!("../../../interface/src/data/order.rs"); + +fn idl() -> Value { + serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON") +} + +fn find_item_in_idl<'a>(idl: &'a Value, type_name: &str, name: &str) -> Option<&'a Value> { + idl[type_name] + .as_array()? + .iter() + .find(|ix| ix["name"] == name) +} + +fn doc_attr_text(attr: &syn::Attribute) -> Option { + if !attr.path().is_ident("doc") { + return None; + } + let syn::Meta::NameValue(nv) = &attr.meta else { + return None; + }; + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = &nv.value + else { + return None; + }; + Some(s.value().trim().to_string()) +} + +fn normalize_doc(lines: &[String]) -> String { + lines + .iter() + .map(|l| l.trim()) + .collect::>() + .join(" ") + .replace('`', "") +} + +// --------------------------------------------------------------------------- +// JSON validity and formatting +// --------------------------------------------------------------------------- + +#[test] +fn idl_is_valid_json() { + let _: Value = idl(); +} + +#[test] +fn idl_is_pretty_formatted() { + let mut formatted = serde_json::to_string_pretty(&idl()).expect("IDL JSON should re-serialize"); + formatted.push('\n'); + assert_eq!( + formatted, IDL_JSON, + "IDL isn't canonically formatted; regenerate it with `serde_json::to_string_pretty` \ + plus a trailing newline" + ); +} + +#[test] +fn idl_address_matches_declared_program_id() { + let idl = idl(); + assert_eq!( + idl["address"].as_str().expect("address must be a string"), + settlement_interface::ID.to_string(), + "IDL `address` must match the program id declared via declare_id! in interface/src/lib.rs" + ); +} + +#[test] +fn idl_conforms_to_official_schema() { + let schema: Value = serde_json::from_str(IDL_SCHEMA_JSON).expect("schema should be valid JSON"); + let validator = jsonschema::validator_for(&schema).expect("schema should compile"); + let instance = idl(); + let errors: Vec = validator + .iter_errors(&instance) + .map(|e| e.to_string()) + .collect(); + assert!( + errors.is_empty(), + "IDL fails schema validation:\n{}", + errors.join("\n") + ); +} + +fn pascal_to_snake(s: &str) -> String { + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i != 0 { + out.push('_'); + } + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +fn confirm_idl_match(idl: &Value, byte: u8, element_type: &str, idl_name: &str) { + let idl_element = find_item_in_idl(&idl, element_type, &idl_name).expect(&format!( + "IDL does not contain defined settlement element {idl_name}" + )); + + // confirm the discriminator matches + let disc = idl_element["discriminator"] + .as_array() + .expect(&format!("discriminator for {idl_name} should be an array")); + + assert_eq!( + disc.len(), + 1, + "instruction discriminator {disc:?} for {idl_name} should be 1 byte" + ); + assert_eq!( + disc[0].as_u64(), + Some(byte as u64), + "instruction discriminator byte for {idl_name} doesn't match up with the code" + ); + + // confirm the docs match + /*let docs = ix["docs"] + .as_array() + .expect("docs for {idl_name} should be an array");*/ + + // TODO +} + +#[test] +fn idl_matches_instruction_discriminators() { + let idl = idl(); + for byte in 0u8..=255 { + if let Ok(ix) = SettlementInstruction::try_from(byte) { + confirm_idl_match( + &idl, + byte, + "instructions", + &pascal_to_snake(&format!("{ix:?}")), + ); + } + } +} + +#[test] +fn idl_matches_account_discriminators() { + let idl = idl(); + for byte in 0u8..=255 { + if let Ok(account) = SettlementAccount::try_from(byte) { + confirm_idl_match(&idl, byte, "accounts", &format!("{account:?}")); + } + } +} + +fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { + // load in the idl and rust type definitions + let type_in_idl = idl["types"] + .as_array() + .expect("types must be an array") + .iter() + .find(|t| t["name"] == type_name) + .expect("IDL types[] must contain {type_name}"); + + let file = syn::parse_file(rust_file_name).expect("Rust source must parse"); + let rust_struct = file + .items + .iter() + .find_map(|item| match item { + syn::Item::Struct(s) if s.ident == type_name => Some(s), + _ => None, + }) + .expect("struct {struct_name} not found in Rust source"); + + // confirm the docs match + let idl_docs: Vec = type_in_idl["docs"] + .as_array() + .expect("docs should be an array for {type_name}") + .iter() + .map(|i| i.as_str().expect("doc item should be a string").to_string()) + .collect(); + + let rust_docs: Vec = rust_struct + .attrs + .iter() + .filter_map(|attr| doc_attr_text(attr)) + .collect(); + + assert_eq!( + idl_docs, rust_docs, + "documentation between rust and IDL types should be the same for {type_name}" + ); + + // confirm fields match + let idl_fields: Vec = type_in_idl["type"]["fields"] + .as_array() + .expect("struct type {type_name} should have a fields array") + .iter() + .map(|f| { + f["name"] + .as_str() + .expect("field name must be a string") + .to_string() + }) + .collect(); + + let rust_fields: Vec = rust_struct + .fields + .iter() + .map(|f| { + f.ident + .as_ref() + .expect("{struct_name} should have named fields") + .to_string() + }) + .collect(); + + assert_eq!( + idl_fields, + rust_fields, + "OrderIntent's IDL field list/order must match the Rust struct (field order is load-bearing: it's the wire format)" + ); +} + +#[test] +fn idl_matches_rust_types() { + let idl = idl(); + + confirm_idl_types_entry(&idl, INTENT_RS, "OrderIntent"); + confirm_idl_types_entry(&idl, ORDER_RS, "OrderAccount"); +} + +#[test] +fn idl_matches_rust_errors() { + let rust_file = syn::parse_file(INTERFACE_LIB_RS).expect("interface/src/lib.rs must parse"); + let rust_errors_type = rust_file + .items + .iter() + .find_map(|item| match item { + syn::Item::Enum(e) if e.ident == "SettlementError" => Some(e), + _ => None, + }) + .expect("SettlementError enum must exist in interface/src/lib.rs"); + + let idl = idl(); + for rust_err in &rust_errors_type.variants { + let idl_err = find_item_in_idl(&idl, "errors", &rust_err.ident.to_string()) + .expect("Settlement program error {rust_err} is not defined in IDL errors[]"); + + // the idl error's "code" should match up + let rust_code = match &rust_err.discriminant { + Some(( + _, + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(i), + .. + }), + )) => i + .base10_parse::() + .expect("discriminant must be a u32 literal"), + Some(_) => panic!("unexpected non-literal discriminant on {}", rust_err.ident), + None => panic!("discriminant should be defined on {}", rust_err.ident), + }; + + let idl_code = idl_err["code"] + .as_u64() + .expect("code should be correct type"); + + assert_eq!( + idl_code, rust_code as u64, + "IDL errors[] name={} should match the code in rust", + rust_err.ident + ); + + // the idl error's "msg" should match up + let rust_doc_lines: Vec = rust_err.attrs.iter().filter_map(doc_attr_text).collect(); + let rust_msg = normalize_doc(&rust_doc_lines); + + let idl_msg = idl_err["msg"].as_str().expect("msg must be a string"); + + assert_eq!( + idl_msg, rust_msg, + "IDL errors[] name={} should match informational msg", + rust_err.ident + ); + } +} + +#[test] +fn idl_pda_seed_literals_match_pda_module() { + let known: BTreeSet<&[u8]> = [SETTLEMENT_SEED, BUFFER_SEED, ORDER_SEED] + .into_iter() + .collect(); + + fn collect_const_seeds(value: &Value, out: &mut Vec>) { + match value { + Value::Object(map) => { + if map.get("kind").and_then(Value::as_str) == Some("const") { + if let Some(Value::Array(bytes)) = map.get("value") { + let decoded: Vec = bytes + .iter() + .map(|b| b.as_u64().expect("seed byte must be a number") as u8) + .collect(); + out.push(decoded); + } + } + for v in map.values() { + collect_const_seeds(v, out); + } + } + Value::Array(arr) => arr.iter().for_each(|v| collect_const_seeds(v, out)), + _ => {} + } + } + + let idl = idl(); + let mut found = Vec::new(); + collect_const_seeds(&idl["instructions"], &mut found); + + // Every const seed the IDL does declare must be a real seed constant. Not + // every seed constant needs to show up: `order_pda`'s canonical seed + // includes `sha256(intent)`, which the IDL can't express as a static + // `pda` entry at all (documented in create_order's docs), so `ORDER_SEED` + // legitimately never appears here. + for seed in &found { + assert!( + known.contains(seed.as_slice()), + "IDL PDA const seed {:?} doesn't match any seed constant in interface::pda", + String::from_utf8_lossy(seed), + ); + } + assert!( + found.iter().any(|s| s.as_slice() == SETTLEMENT_SEED), + "expected SETTLEMENT_SEED to appear in some IDL PDA `seeds`", + ); + assert!( + found.iter().any(|s| s.as_slice() == BUFFER_SEED), + "expected BUFFER_SEED to appear in some IDL PDA `seeds`", + ); +} From adf17c59a7e3613d5a29118c41312b926162b76d Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:45:32 +0900 Subject: [PATCH 03/14] fix lint --- programs/settlement/tests/idl.rs | 34 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs index 2ec5aed6..490c63d0 100644 --- a/programs/settlement/tests/idl.rs +++ b/programs/settlement/tests/idl.rs @@ -124,14 +124,13 @@ fn pascal_to_snake(s: &str) -> String { } fn confirm_idl_match(idl: &Value, byte: u8, element_type: &str, idl_name: &str) { - let idl_element = find_item_in_idl(&idl, element_type, &idl_name).expect(&format!( - "IDL does not contain defined settlement element {idl_name}" - )); + let idl_element = find_item_in_idl(idl, element_type, idl_name) + .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); // confirm the discriminator matches let disc = idl_element["discriminator"] .as_array() - .expect(&format!("discriminator for {idl_name} should be an array")); + .unwrap_or_else(|| panic!("discriminator for {idl_name} should be an array")); assert_eq!( disc.len(), @@ -184,7 +183,7 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { .expect("types must be an array") .iter() .find(|t| t["name"] == type_name) - .expect("IDL types[] must contain {type_name}"); + .unwrap_or_else(|| panic!("IDL types[] must contain {type_name}")); let file = syn::parse_file(rust_file_name).expect("Rust source must parse"); let rust_struct = file @@ -194,21 +193,17 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { syn::Item::Struct(s) if s.ident == type_name => Some(s), _ => None, }) - .expect("struct {struct_name} not found in Rust source"); + .unwrap_or_else(|| panic!("struct {type_name} not found in Rust source")); // confirm the docs match let idl_docs: Vec = type_in_idl["docs"] .as_array() - .expect("docs should be an array for {type_name}") + .unwrap_or_else(|| panic!("docs should be an array for {type_name}")) .iter() .map(|i| i.as_str().expect("doc item should be a string").to_string()) .collect(); - let rust_docs: Vec = rust_struct - .attrs - .iter() - .filter_map(|attr| doc_attr_text(attr)) - .collect(); + let rust_docs: Vec = rust_struct.attrs.iter().filter_map(doc_attr_text).collect(); assert_eq!( idl_docs, rust_docs, @@ -218,7 +213,7 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { // confirm fields match let idl_fields: Vec = type_in_idl["type"]["fields"] .as_array() - .expect("struct type {type_name} should have a fields array") + .unwrap_or_else(|| panic!("struct type {type_name} should have a fields array")) .iter() .map(|f| { f["name"] @@ -234,7 +229,7 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { .map(|f| { f.ident .as_ref() - .expect("{struct_name} should have named fields") + .unwrap_or_else(|| panic!("{type_name} should have named fields")) .to_string() }) .collect(); @@ -242,7 +237,7 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { assert_eq!( idl_fields, rust_fields, - "OrderIntent's IDL field list/order must match the Rust struct (field order is load-bearing: it's the wire format)" + "{type_name}'s IDL field list/order must match the Rust struct (field order is load-bearing: it's the wire format)" ); } @@ -268,8 +263,13 @@ fn idl_matches_rust_errors() { let idl = idl(); for rust_err in &rust_errors_type.variants { - let idl_err = find_item_in_idl(&idl, "errors", &rust_err.ident.to_string()) - .expect("Settlement program error {rust_err} is not defined in IDL errors[]"); + let idl_err = + find_item_in_idl(&idl, "errors", &rust_err.ident.to_string()).unwrap_or_else(|| { + panic!( + "Settlement program error {} is not defined in IDL errors[]", + rust_err.ident + ) + }); // the idl error's "code" should match up let rust_code = match &rust_err.discriminant { From eb45059c3e8ff72fdf16e89120fb1400c4f70cf5 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:45:53 +0900 Subject: [PATCH 04/14] sync idl and add a couple more tests to cover everything --- programs/settlement/idl/cow_settlement.json | 80 +++++++++++++++-- programs/settlement/tests/idl.rs | 95 +++++++++++++++------ 2 files changed, 142 insertions(+), 33 deletions(-) diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index 3c2415a9..8d85843f 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -42,13 +42,22 @@ 109, 101, 110, - 116 + 116, + 32, + 118, + 48, + 46, + 49, + 32, + 32, + 32, + 32 ] } ] }, "docs": [ - "Canonical PDA seeded by the literal string \"settlement\"." + "Canonical PDA seeded by SETTLEMENT_SEED alone. SETTLEMENT_SEED is the ASCII prefix \"settlement v\" followed by the program's major.minor version, right-padded with spaces to a fixed 19 bytes; the fixed width prevents one version's seeds from being a prefix of another's. The const bytes below are authoritative and change on every minor version bump." ] }, { @@ -56,14 +65,22 @@ "address": "11111111111111111111111111111111" } ], - "args": [] + "args": [ + { + "name": "reclaim_authority", + "docs": [ + "Recorded verbatim in the state PDA's body as SettlementState.reclaim_authority." + ], + "type": "pubkey" + } + ] }, { "name": "create_buffer", "docs": [ "Creates one or more per-token buffer PDAs (SPL token accounts) in a single instruction.", "IDL MODELING NOTE: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). Anchor's IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0). This instruction is solver/internal tooling only, not part of the public client surface, so buffers beyond the first are intentionally left unmodeled here: callers needing more than one buffer per transaction append the extra (buffer_pda, mint) account-meta pairs by hand after building the base instruction.", - "Each buffer_pda_i must be the canonical PDA for seeds [\"settlement\", mint_i, \"buffer\"]." + "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]." ], "discriminator": [ 4 @@ -99,7 +116,16 @@ 109, 101, 110, - 116 + 116, + 32, + 118, + 48, + 46, + 49, + 32, + 32, + 32, + 32 ] }, { @@ -136,7 +162,7 @@ "name": "create_order", "docs": [ "Allocates a per-order PDA and writes the initial OrderAccount body.", - "order_pda's canonical seeds are [\"settlement\", sha256(intent_bytes), \"order\"]. This is not expressible as a static `pda` entry because the middle seed is a hash of the entire `intent` argument, not a plain field/account reference, which is outside what the Anchor PDA-seed grammar (const / arg / account) can describe." + "order_pda's canonical seeds are [SETTLEMENT_SEED, sha256(intent_bytes), \"order\"]. This is not expressible as a static `pda` entry because the middle seed is a hash of the entire `intent` argument, not a plain field/account reference, which is outside what the Anchor PDA-seed grammar (const / arg / account) can describe." ], "discriminator": [ 2 @@ -348,10 +374,16 @@ { "name": "SettlementState", "docs": [ - "Body of the singleton settlement state PDA. Carries no fields beyond the 1-byte discriminator; its existence and address are what matter." + "Idiomatic representation of the state PDA's body." ], "type": { - "kind": "struct" + "kind": "struct", + "fields": [ + { + "name": "reclaim_authority", + "type": "pubkey" + } + ] } }, { @@ -550,11 +582,41 @@ }, { "code": 24, + "name": "LimitPriceViolated", + "msg": "BeginSettle: a settled order's executed price (amount_out/amount_in) is worse than the order's limit price (buy_amount/sell_amount)." + }, + { + "code": 25, + "name": "PullAmountOverflow", + "msg": "BeginSettle: an order's pull amounts sum to more than u64::MAX." + }, + { + "code": 26, + "name": "FillExceedsOrderAmount", + "msg": "BeginSettle: filling this order would consume more tokens than the maximum the user is willing to trade on this intent. Sell: amount_in > sell_amount; buy: amount_out > buy_amount." + }, + { + "code": 27, + "name": "OrderNotExactlyFilled", + "msg": "BeginSettle: a non-partially_fillable order isn't filled exactly to its amount (either under- or over-filled). Sell: amount_in != sell_amount; buy: total amount_out != buy_amount." + }, + { + "code": 28, + "name": "AmountWithdrawnOverflow", + "msg": "BeginSettle: the order's cumulative amount_withdrawn would exceed u64::MAX once this settlement's pulls are added." + }, + { + "code": 29, + "name": "AmountReceivedOverflow", + "msg": "BeginSettle: the order's cumulative amount_received would exceed u64::MAX once this settlement's push is added." + }, + { + "code": 30, "name": "OrderNotExpired", "msg": "ReclaimOrder was called before the order's valid_to has elapsed." }, { - "code": 25, + "code": 31, "name": "ReclaimRecipientMismatch", "msg": "ReclaimOrder's reclaim_recipient account doesn't match the created_by address recorded in the order." } diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs index 490c63d0..134f48c7 100644 --- a/programs/settlement/tests/idl.rs +++ b/programs/settlement/tests/idl.rs @@ -13,7 +13,7 @@ mod common; use std::collections::BTreeSet; -use serde_json::Value; +use serde_json::{json, Value}; use settlement_interface::{ pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, SettlementAccount, SettlementInstruction, @@ -24,6 +24,7 @@ const IDL_SCHEMA_JSON: &str = include_str!("../idl/schema/idl-spec-v0.1.0.json") const INTERFACE_LIB_RS: &str = include_str!("../../../interface/src/lib.rs"); const INTENT_RS: &str = include_str!("../../../interface/src/data/intent.rs"); const ORDER_RS: &str = include_str!("../../../interface/src/data/order.rs"); +const STATE_RS: &str = include_str!("../../../interface/src/data/state.rs"); fn idl() -> Value { serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON") @@ -176,29 +177,69 @@ fn idl_matches_account_discriminators() { } } -fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { +/// Translates a Rust field type into the IDL spec's type grammar, so field +/// types can be compared as JSON. Panics on anything the program's data types +/// don't currently use. +fn rust_type_to_idl(ty: &syn::Type, context: &str) -> Value { + match ty { + syn::Type::Path(path) => { + let ident = path + .path + .get_ident() + .unwrap_or_else(|| panic!("{context}: expected a plain type name")) + .to_string(); + match ident.as_str() { + "Pubkey" => json!("pubkey"), + "bool" | "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" + | "i128" => json!(ident), + // Anything else is one of this crate's own types, which the IDL + // carries as its own `types[]` entry and references by name. + _ => json!({ "defined": { "name": ident } }), + } + } + syn::Type::Array(array) => { + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(len), + .. + }) = &array.len + else { + panic!("{context}: array length must be an integer literal"); + }; + let len: u64 = len.base10_parse().expect("array length must be a u64"); + json!({ "array": [rust_type_to_idl(&array.elem, context), len] }) + } + _ => panic!("{context}: unsupported field type"), + } +} + +/// Cross-checks one IDL `types[]` entry against the Rust struct it describes. +/// +/// `idl_name` is passed separately because the two don't always agree: +/// `StateAccount` is called `SettlementState` in the IDL, matching the +/// `SettlementAccount` discriminator variant that names the account. +fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, rust_type_name: &str, idl_type_name: &str) { // load in the idl and rust type definitions let type_in_idl = idl["types"] .as_array() .expect("types must be an array") .iter() - .find(|t| t["name"] == type_name) - .unwrap_or_else(|| panic!("IDL types[] must contain {type_name}")); + .find(|t| t["name"] == idl_type_name) + .unwrap_or_else(|| panic!("IDL types[] must contain {idl_type_name}")); let file = syn::parse_file(rust_file_name).expect("Rust source must parse"); let rust_struct = file .items .iter() .find_map(|item| match item { - syn::Item::Struct(s) if s.ident == type_name => Some(s), + syn::Item::Struct(s) if s.ident == rust_type_name => Some(s), _ => None, }) - .unwrap_or_else(|| panic!("struct {type_name} not found in Rust source")); + .unwrap_or_else(|| panic!("struct {rust_type_name} not found in Rust source")); // confirm the docs match let idl_docs: Vec = type_in_idl["docs"] .as_array() - .unwrap_or_else(|| panic!("docs should be an array for {type_name}")) + .unwrap_or_else(|| panic!("docs should be an array for {rust_type_name}")) .iter() .map(|i| i.as_str().expect("doc item should be a string").to_string()) .collect(); @@ -207,37 +248,42 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { assert_eq!( idl_docs, rust_docs, - "documentation between rust and IDL types should be the same for {type_name}" + "documentation between rust and IDL types should be the same for {rust_type_name} and {idl_type_name}" ); - // confirm fields match - let idl_fields: Vec = type_in_idl["type"]["fields"] + // confirm fields match, both in name/order and in type + let idl_fields: Vec<(String, Value)> = type_in_idl["type"]["fields"] .as_array() - .unwrap_or_else(|| panic!("struct type {type_name} should have a fields array")) + .unwrap_or_else(|| panic!("struct type {idl_type_name} should have a fields array")) .iter() .map(|f| { - f["name"] - .as_str() - .expect("field name must be a string") - .to_string() + ( + f["name"] + .as_str() + .expect("field name must be a string") + .to_string(), + f["type"].clone(), + ) }) .collect(); - let rust_fields: Vec = rust_struct + let rust_fields: Vec<(String, Value)> = rust_struct .fields .iter() .map(|f| { - f.ident + let name = f + .ident .as_ref() - .unwrap_or_else(|| panic!("{type_name} should have named fields")) - .to_string() + .unwrap_or_else(|| panic!("{idl_type_name} should have named fields")) + .to_string(); + let idl_type = rust_type_to_idl(&f.ty, &format!("{idl_type_name}.{name}")); + (name, idl_type) }) .collect(); assert_eq!( - idl_fields, - rust_fields, - "{type_name}'s IDL field list/order must match the Rust struct (field order is load-bearing: it's the wire format)" + idl_fields, rust_fields, + "{idl_type_name}'s IDL fields must match the Rust struct {idl_type_name} in name, order and type" ); } @@ -245,8 +291,9 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, type_name: &str) { fn idl_matches_rust_types() { let idl = idl(); - confirm_idl_types_entry(&idl, INTENT_RS, "OrderIntent"); - confirm_idl_types_entry(&idl, ORDER_RS, "OrderAccount"); + confirm_idl_types_entry(&idl, INTENT_RS, "OrderIntent", "OrderIntent"); + confirm_idl_types_entry(&idl, ORDER_RS, "OrderAccount", "OrderAccount"); + confirm_idl_types_entry(&idl, STATE_RS, "StateAccount", "SettlementState"); } #[test] From 541bcee3dfc6f629b03dbdeadf913d85b7cd11b3 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:01 +0900 Subject: [PATCH 05/14] Update programs/settlement/tests/idl.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/tests/idl.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs index 134f48c7..0fef6c24 100644 --- a/programs/settlement/tests/idl.rs +++ b/programs/settlement/tests/idl.rs @@ -55,12 +55,7 @@ fn doc_attr_text(attr: &syn::Attribute) -> Option { } fn normalize_doc(lines: &[String]) -> String { - lines - .iter() - .map(|l| l.trim()) - .collect::>() - .join(" ") - .replace('`', "") + lines.join(" ").replace('`', "") } // --------------------------------------------------------------------------- From 4d04660d95ff61054f4763c707d0734720bf9540 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:41 +0900 Subject: [PATCH 06/14] Update programs/settlement/tests/idl.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/tests/idl.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs index 0fef6c24..bea1984a 100644 --- a/programs/settlement/tests/idl.rs +++ b/programs/settlement/tests/idl.rs @@ -51,7 +51,10 @@ fn doc_attr_text(attr: &syn::Attribute) -> Option { else { return None; }; - Some(s.value().trim().to_string()) + // Doc comments start with spaces. We want to remove that, + // but we want to keep extra alignment spacing if present. + let doc = s.value(); + Some(doc.strip_prefix(' ').unwrap_or(&doc).to_string()) } fn normalize_doc(lines: &[String]) -> String { From bae432644296822afdce5325c00062214bbdda6d Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:29:00 +0900 Subject: [PATCH 07/14] various fixes from feedback * add comments for settlement instruction and validate match * simplify superfluous comments in the IDL in general * switch to using `LazyLock` and update call sites --- interface/src/lib.rs | 17 ++ programs/settlement/idl/cow_settlement.json | 17 +- .../idl/schema/idl-spec-v0.1.0.json | 2 +- programs/settlement/tests/idl.rs | 152 +++++++++++++----- 4 files changed, 135 insertions(+), 53 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 03dabac5..c88a7737 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -17,11 +17,28 @@ pub mod pda; constructor = SettlementInstruction::unknown_discriminator, ))] pub enum SettlementInstruction { + /// Pulls funds for a batch of orders. Must be paired in the same + /// transaction with a `FinalizeSettle` at `finalize_ix_index`. BeginSettle = 0, + /// Validates that a `BeginSettle` at `begin_ix_index` exists and points + /// back at this instruction. Must not be called via CPI. FinalizeSettle = 1, + /// Allocates a per-order PDA and writes the initial `OrderAccount` body. CreateOrder = 2, + /// Creates the singleton settlement state PDA. Succeeds only once. Initialize = 3, + /// Creates one or more per-token buffer PDAs (SPL token accounts) in a + /// single instruction. + /// + /// Each buffer_pda_i must be the canonical PDA for seeds + /// [SETTLEMENT_SEED, mint_i, "buffer"]. CreateBuffer = 4, + /// Closes an expired order PDA and returns its rent lamports to the + /// created_by account recorded in the order body. The instruction may only + /// be executed after the order's valid_to timestamp has elapsed. + /// + /// No signature requirement: anyone may reclaim an expired order on behalf + /// of its reclaim_recipient. ReclaimOrder = 5, } diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index 8d85843f..9d3c7a3f 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -4,14 +4,14 @@ "name": "cow_settlement", "version": "0.1.0", "spec": "0.1.0", - "description": "CoW Protocol settlement program. HAND-WRITTEN IDL: this program is a native/Pinocchio program, not built with the Anchor framework, so this file was authored manually to describe its wire format as closely as the Anchor IDL grammar allows. See the per-instruction/type docs for spots where the on-chain format cannot be fully expressed (non-standard 1-byte instruction discriminators, and BeginSettle's dynamically-shaped tail)." + "description": "CoW Protocol settlement program. IDL may not be able to generate typical of Anchor programs, so please be careful to read the documentation on the function you are using.", + "repository": "https://github.com/cowprotocol/solana-programs" }, "instructions": [ { "name": "initialize", "docs": [ - "Creates the singleton settlement state PDA. Succeeds only once.", - "Non-standard discriminator: this program uses a single instruction-selector byte (see SettlementInstruction in the Rust source), not Anchor's usual 8-byte sighash. The `discriminator` below reflects the real on-chain bytes." + "Creates the singleton settlement state PDA. Succeeds only once." ], "discriminator": [ 3 @@ -79,7 +79,7 @@ "name": "create_buffer", "docs": [ "Creates one or more per-token buffer PDAs (SPL token accounts) in a single instruction.", - "IDL MODELING NOTE: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). Anchor's IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0). This instruction is solver/internal tooling only, not part of the public client surface, so buffers beyond the first are intentionally left unmodeled here: callers needing more than one buffer per transaction append the extra (buffer_pda, mint) account-meta pairs by hand after building the base instruction.", + "IDL LIMITATION: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0).", "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]." ], "discriminator": [ @@ -162,7 +162,7 @@ "name": "create_order", "docs": [ "Allocates a per-order PDA and writes the initial OrderAccount body.", - "order_pda's canonical seeds are [SETTLEMENT_SEED, sha256(intent_bytes), \"order\"]. This is not expressible as a static `pda` entry because the middle seed is a hash of the entire `intent` argument, not a plain field/account reference, which is outside what the Anchor PDA-seed grammar (const / arg / account) can describe." + "order_pda's canonical seeds are [SETTLEMENT_SEED, sha256(intent_bytes), \"order\"]. This is not expressible as a static `pda` entry because the middle seed is a hash of the entire `intent` argument, not a plain field/account reference, which is outside what the PDA-seed grammar can describe." ], "discriminator": [ 2 @@ -210,8 +210,7 @@ "name": "begin_settle", "docs": [ "Pulls funds for a batch of orders. Must be paired in the same transaction with a FinalizeSettle at `finalize_ix_index`.", - "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index, auction_id) is represented as typed arguments. After it, the real wire format is `[order_count: u8][bump; order_count][transfer_count; order_count][amount: u64 LE; sum(transfer_count)]` — a hand-packed layout with no Borsh length prefixes and a trailing array whose length depends on the sum of an earlier array. This has no representation in the Anchor/Borsh/IDL type grammar (no vec/array type can express 'shared count governs several sibling arrays' or 'length = sum of another field'), so it is intentionally left out of `args` rather than mislabeled as `bytes` (which would imply a Borsh Vec length prefix that isn't actually present and would make a generic decoder misparse it). Anchor/Solscan-style tooling will only decode the discriminator, finalize_ix_index, and auction_id for this instruction; the remainder needs bespoke client logic.", - "Per-order accounts follow the 3 shared accounts below as a repeated remaining-accounts group: [order_pda (readonly), sell_token_account (writable), destination (writable) x transfer_count], sorted by ascending order_pda address." + "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index, auction_id) is represented as typed arguments. After it, the real wire format is `[order_count: u8][bump; order_count][transfer_count; order_count][amount: u64 LE; sum(transfer_count)]`. Please check the rust documentation for more information." ], "discriminator": [ 0 @@ -252,9 +251,7 @@ { "name": "finalize_settle", "docs": [ - "Validates that a BeginSettle at `begin_ix_index` exists and points back at this instruction. Must not be called via CPI.", - "Non-standard discriminator, see the note on `initialize`.", - "Per-push accounts follow the 3 shared accounts below as a repeated remaining-accounts group: [source_buffer (writable), destination (writable)] x number of pushes." + "Validates that a BeginSettle at `begin_ix_index` exists and points back at this instruction. Must not be called via CPI." ], "discriminator": [ 1 diff --git a/programs/settlement/idl/schema/idl-spec-v0.1.0.json b/programs/settlement/idl/schema/idl-spec-v0.1.0.json index e2564ff9..0eec2dff 100644 --- a/programs/settlement/idl/schema/idl-spec-v0.1.0.json +++ b/programs/settlement/idl/schema/idl-spec-v0.1.0.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/solana-idl/idl-spec/schema/v0.1.0.json", + "$id": "https://github.com/solana-foundation/idl-spec/schema/v0.1.0.json", "title": "Solana IDL v0.1.0", "description": "JSON Schema for Solana program Interface Description Language (IDL) files, spec version 0.1.0.", "type": "object", diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs index 134f48c7..414a104b 100644 --- a/programs/settlement/tests/idl.rs +++ b/programs/settlement/tests/idl.rs @@ -11,7 +11,7 @@ mod common; -use std::collections::BTreeSet; +use std::{collections::BTreeSet, sync::LazyLock}; use serde_json::{json, Value}; use settlement_interface::{ @@ -26,12 +26,11 @@ const INTENT_RS: &str = include_str!("../../../interface/src/data/intent.rs"); const ORDER_RS: &str = include_str!("../../../interface/src/data/order.rs"); const STATE_RS: &str = include_str!("../../../interface/src/data/state.rs"); -fn idl() -> Value { - serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON") -} +static IDL: LazyLock = + LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); -fn find_item_in_idl<'a>(idl: &'a Value, type_name: &str, name: &str) -> Option<&'a Value> { - idl[type_name] +fn find_item_in_idl<'a>(type_name: &str, name: &str) -> Option<&'a Value> { + IDL[type_name] .as_array()? .iter() .find(|ix| ix["name"] == name) @@ -69,12 +68,12 @@ fn normalize_doc(lines: &[String]) -> String { #[test] fn idl_is_valid_json() { - let _: Value = idl(); + let _ = &*IDL; } #[test] fn idl_is_pretty_formatted() { - let mut formatted = serde_json::to_string_pretty(&idl()).expect("IDL JSON should re-serialize"); + let mut formatted = serde_json::to_string_pretty(&*IDL).expect("IDL JSON should re-serialize"); formatted.push('\n'); assert_eq!( formatted, IDL_JSON, @@ -85,9 +84,8 @@ fn idl_is_pretty_formatted() { #[test] fn idl_address_matches_declared_program_id() { - let idl = idl(); assert_eq!( - idl["address"].as_str().expect("address must be a string"), + IDL["address"].as_str().expect("address must be a string"), settlement_interface::ID.to_string(), "IDL `address` must match the program id declared via declare_id! in interface/src/lib.rs" ); @@ -97,11 +95,7 @@ fn idl_address_matches_declared_program_id() { fn idl_conforms_to_official_schema() { let schema: Value = serde_json::from_str(IDL_SCHEMA_JSON).expect("schema should be valid JSON"); let validator = jsonschema::validator_for(&schema).expect("schema should compile"); - let instance = idl(); - let errors: Vec = validator - .iter_errors(&instance) - .map(|e| e.to_string()) - .collect(); + let errors: Vec = validator.iter_errors(&IDL).map(|e| e.to_string()).collect(); assert!( errors.is_empty(), "IDL fails schema validation:\n{}", @@ -124,8 +118,8 @@ fn pascal_to_snake(s: &str) -> String { out } -fn confirm_idl_match(idl: &Value, byte: u8, element_type: &str, idl_name: &str) { - let idl_element = find_item_in_idl(idl, element_type, idl_name) +fn confirm_idl_match(byte: u8, element_type: &str, idl_name: &str) { + let idl_element = find_item_in_idl(element_type, idl_name) .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); // confirm the discriminator matches @@ -144,35 +138,113 @@ fn confirm_idl_match(idl: &Value, byte: u8, element_type: &str, idl_name: &str) "instruction discriminator byte for {idl_name} doesn't match up with the code" ); - // confirm the docs match - /*let docs = ix["docs"] - .as_array() - .expect("docs for {idl_name} should be an array");*/ + // confirm the docs match: everything the Rust enum variant documents must + // show up, in order, in the IDL element's docs. The IDL is allowed to say + // more than the Rust source does; it carries notes about what its own + // grammar can't express (`begin_settle`'s dynamically-shaped tail, for + // one), which have no business being in the program's own docs. + let idl_docs: Vec = idl_element + .get("docs") + .map(|docs| { + docs.as_array() + .unwrap_or_else(|| panic!("docs for {idl_name} should be an array")) + .iter() + .map(|doc| { + let doc = doc + .as_str() + .unwrap_or_else(|| panic!("doc entry for {idl_name} should be a string")) + .to_string(); + normalize_doc(&[doc]) + }) + .collect() + }) + .unwrap_or_default(); + + let rust_docs = discriminator_variant_docs(element_type, byte); + + let mut unmatched_idl_docs = idl_docs.iter(); + for rust_doc in &rust_docs { + assert!( + unmatched_idl_docs.any(|idl_doc| idl_doc == rust_doc), + "IDL docs for {idl_name} don't document what the Rust source does; missing (or \ + out of order) paragraph:\n{rust_doc}\nIDL docs are:\n{idl_docs:#?}" + ); + } +} + +/// Doc paragraphs of the `element_type` discriminator enum variant with +/// discriminant `byte`, one string per paragraph (blank doc lines separate +/// paragraphs), normalized the way IDL doc strings are so they can be compared. +fn discriminator_variant_docs(element_type: &str, byte: u8) -> Vec { + let enum_name = match element_type { + "instructions" => "SettlementInstruction", + "accounts" => "SettlementAccount", + other => panic!("no discriminator enum backs IDL {other}[]"), + }; + + let rust_file = syn::parse_file(INTERFACE_LIB_RS).expect("interface/src/lib.rs must parse"); + let rust_enum = rust_file + .items + .iter() + .find_map(|item| match item { + syn::Item::Enum(e) if e.ident == enum_name => Some(e), + _ => None, + }) + .unwrap_or_else(|| panic!("{enum_name} enum must exist in interface/src/lib.rs")); + + let variant = rust_enum + .variants + .iter() + .find(|variant| match &variant.discriminant { + Some(( + _, + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(i), + .. + }), + )) => i.base10_parse::().ok() == Some(byte), + _ => panic!("every {enum_name} variant must have a u8 literal discriminant"), + }) + .unwrap_or_else(|| panic!("{enum_name} must have a variant with discriminant {byte}")); - // TODO + doc_paragraphs(&variant.attrs) +} + +/// Splits an item's doc comment into paragraphs on blank doc lines, each +/// normalized into the single line the IDL carries it as. +fn doc_paragraphs(attrs: &[syn::Attribute]) -> Vec { + let mut paragraphs = Vec::new(); + let mut paragraph: Vec = Vec::new(); + for line in attrs.iter().filter_map(doc_attr_text) { + if line.is_empty() { + if !paragraph.is_empty() { + paragraphs.push(normalize_doc(¶graph)); + paragraph.clear(); + } + } else { + paragraph.push(line); + } + } + if !paragraph.is_empty() { + paragraphs.push(normalize_doc(¶graph)); + } + paragraphs } #[test] fn idl_matches_instruction_discriminators() { - let idl = idl(); for byte in 0u8..=255 { if let Ok(ix) = SettlementInstruction::try_from(byte) { - confirm_idl_match( - &idl, - byte, - "instructions", - &pascal_to_snake(&format!("{ix:?}")), - ); + confirm_idl_match(byte, "instructions", &pascal_to_snake(&format!("{ix:?}"))); } } } #[test] fn idl_matches_account_discriminators() { - let idl = idl(); for byte in 0u8..=255 { if let Ok(account) = SettlementAccount::try_from(byte) { - confirm_idl_match(&idl, byte, "accounts", &format!("{account:?}")); + confirm_idl_match(byte, "accounts", &format!("{account:?}")); } } } @@ -217,9 +289,9 @@ fn rust_type_to_idl(ty: &syn::Type, context: &str) -> Value { /// `idl_name` is passed separately because the two don't always agree: /// `StateAccount` is called `SettlementState` in the IDL, matching the /// `SettlementAccount` discriminator variant that names the account. -fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, rust_type_name: &str, idl_type_name: &str) { +fn confirm_idl_types_entry(rust_file_name: &str, rust_type_name: &str, idl_type_name: &str) { // load in the idl and rust type definitions - let type_in_idl = idl["types"] + let type_in_idl = IDL["types"] .as_array() .expect("types must be an array") .iter() @@ -289,11 +361,9 @@ fn confirm_idl_types_entry(idl: &Value, rust_file_name: &str, rust_type_name: &s #[test] fn idl_matches_rust_types() { - let idl = idl(); - - confirm_idl_types_entry(&idl, INTENT_RS, "OrderIntent", "OrderIntent"); - confirm_idl_types_entry(&idl, ORDER_RS, "OrderAccount", "OrderAccount"); - confirm_idl_types_entry(&idl, STATE_RS, "StateAccount", "SettlementState"); + confirm_idl_types_entry(INTENT_RS, "OrderIntent", "OrderIntent"); + confirm_idl_types_entry(ORDER_RS, "OrderAccount", "OrderAccount"); + confirm_idl_types_entry(STATE_RS, "StateAccount", "SettlementState"); } #[test] @@ -308,10 +378,9 @@ fn idl_matches_rust_errors() { }) .expect("SettlementError enum must exist in interface/src/lib.rs"); - let idl = idl(); for rust_err in &rust_errors_type.variants { let idl_err = - find_item_in_idl(&idl, "errors", &rust_err.ident.to_string()).unwrap_or_else(|| { + find_item_in_idl("errors", &rust_err.ident.to_string()).unwrap_or_else(|| { panic!( "Settlement program error {} is not defined in IDL errors[]", rust_err.ident @@ -384,9 +453,8 @@ fn idl_pda_seed_literals_match_pda_module() { } } - let idl = idl(); let mut found = Vec::new(); - collect_const_seeds(&idl["instructions"], &mut found); + collect_const_seeds(&IDL["instructions"], &mut found); // Every const seed the IDL does declare must be a real seed constant. Not // every seed constant needs to show up: `order_pda`'s canonical seed From b5b00906087cd72ff09d373065bedeb13cbef0e4 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:35:31 +0900 Subject: [PATCH 08/14] refactor idl tests into a folder with relevant code isolated --- programs/settlement/tests/idl.rs | 477 -------------------- programs/settlement/tests/idl/main.rs | 231 ++++++++++ programs/settlement/tests/idl/parse_json.rs | 113 +++++ programs/settlement/tests/idl/parse_rust.rs | 221 +++++++++ 4 files changed, 565 insertions(+), 477 deletions(-) delete mode 100644 programs/settlement/tests/idl.rs create mode 100644 programs/settlement/tests/idl/main.rs create mode 100644 programs/settlement/tests/idl/parse_json.rs create mode 100644 programs/settlement/tests/idl/parse_rust.rs diff --git a/programs/settlement/tests/idl.rs b/programs/settlement/tests/idl.rs deleted file mode 100644 index a09f98e1..00000000 --- a/programs/settlement/tests/idl.rs +++ /dev/null @@ -1,477 +0,0 @@ -//! IDL correctness tests for `programs/settlement/idl/cow_settlement.json`. -//! -//! This program is a native Pinocchio program with a hand-written IDL (no -//! `anchor idl build`/shank step keeps it in sync), so these tests -//! cross-check the checked-in file against the Rust source it describes -//! instead of trusting it as-is. -//! -//! The vendored schema (`idl/schema/idl-spec-v0.1.0.json`) is a snapshot of -//! ; -//! re-fetch it there if the spec version ever bumps. - -mod common; - -use std::{collections::BTreeSet, sync::LazyLock}; - -use serde_json::{json, Value}; -use settlement_interface::{ - pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, - SettlementAccount, SettlementInstruction, -}; - -const IDL_JSON: &str = include_str!("../idl/cow_settlement.json"); -const IDL_SCHEMA_JSON: &str = include_str!("../idl/schema/idl-spec-v0.1.0.json"); -const INTERFACE_LIB_RS: &str = include_str!("../../../interface/src/lib.rs"); -const INTENT_RS: &str = include_str!("../../../interface/src/data/intent.rs"); -const ORDER_RS: &str = include_str!("../../../interface/src/data/order.rs"); -const STATE_RS: &str = include_str!("../../../interface/src/data/state.rs"); - -static IDL: LazyLock = - LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); - -fn find_item_in_idl<'a>(type_name: &str, name: &str) -> Option<&'a Value> { - IDL[type_name] - .as_array()? - .iter() - .find(|ix| ix["name"] == name) -} - -fn doc_attr_text(attr: &syn::Attribute) -> Option { - if !attr.path().is_ident("doc") { - return None; - } - let syn::Meta::NameValue(nv) = &attr.meta else { - return None; - }; - let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - }) = &nv.value - else { - return None; - }; - // Doc comments start with spaces. We want to remove that, - // but we want to keep extra alignment spacing if present. - let doc = s.value(); - Some(doc.strip_prefix(' ').unwrap_or(&doc).to_string()) -} - -fn normalize_doc(lines: &[String]) -> String { - lines.join(" ").replace('`', "") -} - -// --------------------------------------------------------------------------- -// JSON validity and formatting -// --------------------------------------------------------------------------- - -#[test] -fn idl_is_valid_json() { - let _ = &*IDL; -} - -#[test] -fn idl_is_pretty_formatted() { - let mut formatted = serde_json::to_string_pretty(&*IDL).expect("IDL JSON should re-serialize"); - formatted.push('\n'); - assert_eq!( - formatted, IDL_JSON, - "IDL isn't canonically formatted; regenerate it with `serde_json::to_string_pretty` \ - plus a trailing newline" - ); -} - -#[test] -fn idl_address_matches_declared_program_id() { - assert_eq!( - IDL["address"].as_str().expect("address must be a string"), - settlement_interface::ID.to_string(), - "IDL `address` must match the program id declared via declare_id! in interface/src/lib.rs" - ); -} - -#[test] -fn idl_conforms_to_official_schema() { - let schema: Value = serde_json::from_str(IDL_SCHEMA_JSON).expect("schema should be valid JSON"); - let validator = jsonschema::validator_for(&schema).expect("schema should compile"); - let errors: Vec = validator.iter_errors(&IDL).map(|e| e.to_string()).collect(); - assert!( - errors.is_empty(), - "IDL fails schema validation:\n{}", - errors.join("\n") - ); -} - -fn pascal_to_snake(s: &str) -> String { - let mut out = String::new(); - for (i, c) in s.chars().enumerate() { - if c.is_uppercase() { - if i != 0 { - out.push('_'); - } - out.extend(c.to_lowercase()); - } else { - out.push(c); - } - } - out -} - -fn confirm_idl_match(byte: u8, element_type: &str, idl_name: &str) { - let idl_element = find_item_in_idl(element_type, idl_name) - .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); - - // confirm the discriminator matches - let disc = idl_element["discriminator"] - .as_array() - .unwrap_or_else(|| panic!("discriminator for {idl_name} should be an array")); - - assert_eq!( - disc.len(), - 1, - "instruction discriminator {disc:?} for {idl_name} should be 1 byte" - ); - assert_eq!( - disc[0].as_u64(), - Some(byte as u64), - "instruction discriminator byte for {idl_name} doesn't match up with the code" - ); - - // confirm the docs match: everything the Rust enum variant documents must - // show up, in order, in the IDL element's docs. The IDL is allowed to say - // more than the Rust source does; it carries notes about what its own - // grammar can't express (`begin_settle`'s dynamically-shaped tail, for - // one), which have no business being in the program's own docs. - let idl_docs: Vec = idl_element - .get("docs") - .map(|docs| { - docs.as_array() - .unwrap_or_else(|| panic!("docs for {idl_name} should be an array")) - .iter() - .map(|doc| { - let doc = doc - .as_str() - .unwrap_or_else(|| panic!("doc entry for {idl_name} should be a string")) - .to_string(); - normalize_doc(&[doc]) - }) - .collect() - }) - .unwrap_or_default(); - - let rust_docs = discriminator_variant_docs(element_type, byte); - - let mut unmatched_idl_docs = idl_docs.iter(); - for rust_doc in &rust_docs { - assert!( - unmatched_idl_docs.any(|idl_doc| idl_doc == rust_doc), - "IDL docs for {idl_name} don't document what the Rust source does; missing (or \ - out of order) paragraph:\n{rust_doc}\nIDL docs are:\n{idl_docs:#?}" - ); - } -} - -/// Doc paragraphs of the `element_type` discriminator enum variant with -/// discriminant `byte`, one string per paragraph (blank doc lines separate -/// paragraphs), normalized the way IDL doc strings are so they can be compared. -fn discriminator_variant_docs(element_type: &str, byte: u8) -> Vec { - let enum_name = match element_type { - "instructions" => "SettlementInstruction", - "accounts" => "SettlementAccount", - other => panic!("no discriminator enum backs IDL {other}[]"), - }; - - let rust_file = syn::parse_file(INTERFACE_LIB_RS).expect("interface/src/lib.rs must parse"); - let rust_enum = rust_file - .items - .iter() - .find_map(|item| match item { - syn::Item::Enum(e) if e.ident == enum_name => Some(e), - _ => None, - }) - .unwrap_or_else(|| panic!("{enum_name} enum must exist in interface/src/lib.rs")); - - let variant = rust_enum - .variants - .iter() - .find(|variant| match &variant.discriminant { - Some(( - _, - syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Int(i), - .. - }), - )) => i.base10_parse::().ok() == Some(byte), - _ => panic!("every {enum_name} variant must have a u8 literal discriminant"), - }) - .unwrap_or_else(|| panic!("{enum_name} must have a variant with discriminant {byte}")); - - doc_paragraphs(&variant.attrs) -} - -/// Splits an item's doc comment into paragraphs on blank doc lines, each -/// normalized into the single line the IDL carries it as. -fn doc_paragraphs(attrs: &[syn::Attribute]) -> Vec { - let mut paragraphs = Vec::new(); - let mut paragraph: Vec = Vec::new(); - for line in attrs.iter().filter_map(doc_attr_text) { - if line.is_empty() { - if !paragraph.is_empty() { - paragraphs.push(normalize_doc(¶graph)); - paragraph.clear(); - } - } else { - paragraph.push(line); - } - } - if !paragraph.is_empty() { - paragraphs.push(normalize_doc(¶graph)); - } - paragraphs -} - -#[test] -fn idl_matches_instruction_discriminators() { - for byte in 0u8..=255 { - if let Ok(ix) = SettlementInstruction::try_from(byte) { - confirm_idl_match(byte, "instructions", &pascal_to_snake(&format!("{ix:?}"))); - } - } -} - -#[test] -fn idl_matches_account_discriminators() { - for byte in 0u8..=255 { - if let Ok(account) = SettlementAccount::try_from(byte) { - confirm_idl_match(byte, "accounts", &format!("{account:?}")); - } - } -} - -/// Translates a Rust field type into the IDL spec's type grammar, so field -/// types can be compared as JSON. Panics on anything the program's data types -/// don't currently use. -fn rust_type_to_idl(ty: &syn::Type, context: &str) -> Value { - match ty { - syn::Type::Path(path) => { - let ident = path - .path - .get_ident() - .unwrap_or_else(|| panic!("{context}: expected a plain type name")) - .to_string(); - match ident.as_str() { - "Pubkey" => json!("pubkey"), - "bool" | "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" - | "i128" => json!(ident), - // Anything else is one of this crate's own types, which the IDL - // carries as its own `types[]` entry and references by name. - _ => json!({ "defined": { "name": ident } }), - } - } - syn::Type::Array(array) => { - let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Int(len), - .. - }) = &array.len - else { - panic!("{context}: array length must be an integer literal"); - }; - let len: u64 = len.base10_parse().expect("array length must be a u64"); - json!({ "array": [rust_type_to_idl(&array.elem, context), len] }) - } - _ => panic!("{context}: unsupported field type"), - } -} - -/// Cross-checks one IDL `types[]` entry against the Rust struct it describes. -/// -/// `idl_name` is passed separately because the two don't always agree: -/// `StateAccount` is called `SettlementState` in the IDL, matching the -/// `SettlementAccount` discriminator variant that names the account. -fn confirm_idl_types_entry(rust_file_name: &str, rust_type_name: &str, idl_type_name: &str) { - // load in the idl and rust type definitions - let type_in_idl = IDL["types"] - .as_array() - .expect("types must be an array") - .iter() - .find(|t| t["name"] == idl_type_name) - .unwrap_or_else(|| panic!("IDL types[] must contain {idl_type_name}")); - - let file = syn::parse_file(rust_file_name).expect("Rust source must parse"); - let rust_struct = file - .items - .iter() - .find_map(|item| match item { - syn::Item::Struct(s) if s.ident == rust_type_name => Some(s), - _ => None, - }) - .unwrap_or_else(|| panic!("struct {rust_type_name} not found in Rust source")); - - // confirm the docs match - let idl_docs: Vec = type_in_idl["docs"] - .as_array() - .unwrap_or_else(|| panic!("docs should be an array for {rust_type_name}")) - .iter() - .map(|i| i.as_str().expect("doc item should be a string").to_string()) - .collect(); - - let rust_docs: Vec = rust_struct.attrs.iter().filter_map(doc_attr_text).collect(); - - assert_eq!( - idl_docs, rust_docs, - "documentation between rust and IDL types should be the same for {rust_type_name} and {idl_type_name}" - ); - - // confirm fields match, both in name/order and in type - let idl_fields: Vec<(String, Value)> = type_in_idl["type"]["fields"] - .as_array() - .unwrap_or_else(|| panic!("struct type {idl_type_name} should have a fields array")) - .iter() - .map(|f| { - ( - f["name"] - .as_str() - .expect("field name must be a string") - .to_string(), - f["type"].clone(), - ) - }) - .collect(); - - let rust_fields: Vec<(String, Value)> = rust_struct - .fields - .iter() - .map(|f| { - let name = f - .ident - .as_ref() - .unwrap_or_else(|| panic!("{idl_type_name} should have named fields")) - .to_string(); - let idl_type = rust_type_to_idl(&f.ty, &format!("{idl_type_name}.{name}")); - (name, idl_type) - }) - .collect(); - - assert_eq!( - idl_fields, rust_fields, - "{idl_type_name}'s IDL fields must match the Rust struct {idl_type_name} in name, order and type" - ); -} - -#[test] -fn idl_matches_rust_types() { - confirm_idl_types_entry(INTENT_RS, "OrderIntent", "OrderIntent"); - confirm_idl_types_entry(ORDER_RS, "OrderAccount", "OrderAccount"); - confirm_idl_types_entry(STATE_RS, "StateAccount", "SettlementState"); -} - -#[test] -fn idl_matches_rust_errors() { - let rust_file = syn::parse_file(INTERFACE_LIB_RS).expect("interface/src/lib.rs must parse"); - let rust_errors_type = rust_file - .items - .iter() - .find_map(|item| match item { - syn::Item::Enum(e) if e.ident == "SettlementError" => Some(e), - _ => None, - }) - .expect("SettlementError enum must exist in interface/src/lib.rs"); - - for rust_err in &rust_errors_type.variants { - let idl_err = - find_item_in_idl("errors", &rust_err.ident.to_string()).unwrap_or_else(|| { - panic!( - "Settlement program error {} is not defined in IDL errors[]", - rust_err.ident - ) - }); - - // the idl error's "code" should match up - let rust_code = match &rust_err.discriminant { - Some(( - _, - syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Int(i), - .. - }), - )) => i - .base10_parse::() - .expect("discriminant must be a u32 literal"), - Some(_) => panic!("unexpected non-literal discriminant on {}", rust_err.ident), - None => panic!("discriminant should be defined on {}", rust_err.ident), - }; - - let idl_code = idl_err["code"] - .as_u64() - .expect("code should be correct type"); - - assert_eq!( - idl_code, rust_code as u64, - "IDL errors[] name={} should match the code in rust", - rust_err.ident - ); - - // the idl error's "msg" should match up - let rust_doc_lines: Vec = rust_err.attrs.iter().filter_map(doc_attr_text).collect(); - let rust_msg = normalize_doc(&rust_doc_lines); - - let idl_msg = idl_err["msg"].as_str().expect("msg must be a string"); - - assert_eq!( - idl_msg, rust_msg, - "IDL errors[] name={} should match informational msg", - rust_err.ident - ); - } -} - -#[test] -fn idl_pda_seed_literals_match_pda_module() { - let known: BTreeSet<&[u8]> = [SETTLEMENT_SEED, BUFFER_SEED, ORDER_SEED] - .into_iter() - .collect(); - - fn collect_const_seeds(value: &Value, out: &mut Vec>) { - match value { - Value::Object(map) => { - if map.get("kind").and_then(Value::as_str) == Some("const") { - if let Some(Value::Array(bytes)) = map.get("value") { - let decoded: Vec = bytes - .iter() - .map(|b| b.as_u64().expect("seed byte must be a number") as u8) - .collect(); - out.push(decoded); - } - } - for v in map.values() { - collect_const_seeds(v, out); - } - } - Value::Array(arr) => arr.iter().for_each(|v| collect_const_seeds(v, out)), - _ => {} - } - } - - let mut found = Vec::new(); - collect_const_seeds(&IDL["instructions"], &mut found); - - // Every const seed the IDL does declare must be a real seed constant. Not - // every seed constant needs to show up: `order_pda`'s canonical seed - // includes `sha256(intent)`, which the IDL can't express as a static - // `pda` entry at all (documented in create_order's docs), so `ORDER_SEED` - // legitimately never appears here. - for seed in &found { - assert!( - known.contains(seed.as_slice()), - "IDL PDA const seed {:?} doesn't match any seed constant in interface::pda", - String::from_utf8_lossy(seed), - ); - } - assert!( - found.iter().any(|s| s.as_slice() == SETTLEMENT_SEED), - "expected SETTLEMENT_SEED to appear in some IDL PDA `seeds`", - ); - assert!( - found.iter().any(|s| s.as_slice() == BUFFER_SEED), - "expected BUFFER_SEED to appear in some IDL PDA `seeds`", - ); -} diff --git a/programs/settlement/tests/idl/main.rs b/programs/settlement/tests/idl/main.rs new file mode 100644 index 00000000..fea546ab --- /dev/null +++ b/programs/settlement/tests/idl/main.rs @@ -0,0 +1,231 @@ +//! IDL correctness tests for `programs/settlement/idl/cow_settlement.json`. +//! +//! This program is a native Pinocchio program with a hand-written IDL (no +//! `anchor idl build`/shank step keeps it in sync), so these tests +//! cross-check the checked-in file against the Rust source it describes. +//! +//! [`parse_json`] reads the IDL and [`parse_rust`] reads the Rust source; the +//! tests here only compare what the two report. + +mod parse_json; +mod parse_rust; + +use std::collections::BTreeSet; + +use parse_json::IDL; +use serde_json::Value; +use settlement_interface::{ + pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, + SettlementAccount, SettlementInstruction, +}; + +fn pascal_to_snake(s: &str) -> String { + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i != 0 { + out.push('_'); + } + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +fn confirm_idl_match(byte: u8, element_type: &str, idl_name: &str) { + let idl_element = parse_json::find_item(element_type, idl_name) + .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); + + // confirm the discriminator matches + assert_eq!( + parse_json::discriminator(idl_element, idl_name), + vec![byte], + "IDL discriminator for {idl_name} should be the single byte the code uses" + ); + + // confirm the docs match: everything the Rust enum variant documents must + // show up, in order, in the IDL element's docs. The IDL is allowed to say + // more than the Rust source does; it carries notes about what its own + // grammar can't express (`begin_settle`'s dynamically-shaped tail, for + // one), which have no business being in the program's own docs. + let idl_docs = parse_json::docs(idl_element, idl_name); + let rust_docs = parse_rust::discriminator_variant_docs(element_type, byte); + + let mut unmatched_idl_docs = idl_docs.iter(); + for rust_doc in &rust_docs { + assert!( + unmatched_idl_docs.any(|idl_doc| idl_doc == rust_doc), + "IDL docs for {idl_name} don't document what the Rust source does; missing (or \ + out of order) paragraph:\n{rust_doc}\nIDL docs are:\n{idl_docs:#?}" + ); + } +} + +// --------------------------------------------------------------------------- +// JSON validity and formatting +// --------------------------------------------------------------------------- + +#[test] +fn idl_is_valid_json() { + let _ = &*IDL; +} + +#[test] +fn idl_is_pretty_formatted() { + let mut formatted = serde_json::to_string_pretty(&*IDL).expect("IDL JSON should re-serialize"); + formatted.push('\n'); + assert_eq!( + formatted, + parse_json::IDL_JSON, + "IDL isn't canonically formatted; regenerate it with `serde_json::to_string_pretty` \ + plus a trailing newline" + ); +} + +#[test] +fn idl_address_matches_declared_program_id() { + assert_eq!( + IDL["address"].as_str().expect("address must be a string"), + settlement_interface::ID.to_string(), + "IDL `address` must match the program id declared via declare_id! in interface/src/lib.rs" + ); +} + +#[test] +fn idl_conforms_to_official_schema() { + let schema: Value = + serde_json::from_str(parse_json::SCHEMA_JSON).expect("schema should be valid JSON"); + let validator = jsonschema::validator_for(&schema).expect("schema should compile"); + let errors: Vec = validator.iter_errors(&IDL).map(|e| e.to_string()).collect(); + assert!( + errors.is_empty(), + "IDL fails schema validation:\n{}", + errors.join("\n") + ); +} + +#[test] +fn idl_matches_instruction_discriminators() { + for byte in 0u8..=255 { + if let Ok(ix) = SettlementInstruction::try_from(byte) { + confirm_idl_match(byte, "instructions", &pascal_to_snake(&format!("{ix:?}"))); + } + } +} + +#[test] +fn idl_matches_account_discriminators() { + for byte in 0u8..=255 { + if let Ok(account) = SettlementAccount::try_from(byte) { + confirm_idl_match(byte, "accounts", &format!("{account:?}")); + } + } +} + +/// Cross-checks one IDL `types[]` entry against the Rust struct it describes. +/// +/// `idl_name` is passed separately because the two don't always agree: +/// `StateAccount` is called `SettlementState` in the IDL, matching the +/// `SettlementAccount` discriminator variant that names the account. +fn confirm_idl_types_entry( + rust_source: &parse_rust::Source, + rust_type_name: &str, + idl_type_name: &str, +) { + // load in the idl and rust type definitions + let type_in_idl = parse_json::find_item("types", idl_type_name) + .unwrap_or_else(|| panic!("IDL types[] must contain {idl_type_name}")); + let rust_struct = rust_source.find_struct(rust_type_name); + + // confirm the docs match. `types[]` carries one docs entry per source + // line where `instructions[]` carries one per paragraph, so both sides are + // compared as a single joined block: same prose, wrapping not load-bearing. + assert_eq!( + parse_json::docs(type_in_idl, idl_type_name).join(" "), + parse_rust::docs(&rust_struct.attrs).join(" "), + "documentation between rust and IDL types should be the same for {rust_type_name} and {idl_type_name}" + ); + + // confirm fields match, both in name/order and in type + assert_eq!( + parse_json::struct_fields(type_in_idl, idl_type_name), + parse_rust::struct_fields(&rust_struct, idl_type_name), + "{idl_type_name}'s IDL fields must match the Rust struct {idl_type_name} in name, order and type" + ); +} + +#[test] +fn idl_matches_rust_types() { + confirm_idl_types_entry(&parse_rust::INTENT_RS, "OrderIntent", "OrderIntent"); + confirm_idl_types_entry(&parse_rust::ORDER_RS, "OrderAccount", "OrderAccount"); + confirm_idl_types_entry(&parse_rust::STATE_RS, "StateAccount", "SettlementState"); +} + +#[test] +fn idl_matches_rust_errors() { + let rust_errors_type = parse_rust::INTERFACE_LIB_RS.find_enum("SettlementError"); + + for rust_err in &rust_errors_type.variants { + let idl_err = + parse_json::find_item("errors", &rust_err.ident.to_string()).unwrap_or_else(|| { + panic!( + "Settlement program error {} is not defined in IDL errors[]", + rust_err.ident + ) + }); + + // the idl error's "code" should match up + let idl_code = idl_err["code"] + .as_u64() + .expect("code should be correct type"); + + assert_eq!( + idl_code, + parse_rust::discriminant(rust_err), + "IDL errors[] name={} should match the code in rust", + rust_err.ident + ); + + // the idl error's "msg" should match up + let rust_msg = parse_rust::docs(&rust_err.attrs).join(" "); + let idl_msg = idl_err["msg"].as_str().expect("msg must be a string"); + + assert_eq!( + idl_msg, rust_msg, + "IDL errors[] name={} should match informational msg", + rust_err.ident + ); + } +} + +#[test] +fn idl_pda_seed_literals_match_pda_module() { + let known: BTreeSet<&[u8]> = [SETTLEMENT_SEED, BUFFER_SEED, ORDER_SEED] + .into_iter() + .collect(); + + let found = parse_json::const_seeds(&IDL["instructions"]); + + // Every const seed the IDL does declare must be a real seed constant. Not + // every seed constant needs to show up: `order_pda`'s canonical seed + // includes `sha256(intent)`, which the IDL can't express as a static + // `pda` entry at all (documented in create_order's docs), so `ORDER_SEED` + // legitimately never appears here. + for seed in &found { + assert!( + known.contains(seed.as_slice()), + "IDL PDA const seed {:?} doesn't match any seed constant in interface::pda", + String::from_utf8_lossy(seed), + ); + } + assert!( + found.iter().any(|s| s.as_slice() == SETTLEMENT_SEED), + "expected SETTLEMENT_SEED to appear in some IDL PDA `seeds`", + ); + assert!( + found.iter().any(|s| s.as_slice() == BUFFER_SEED), + "expected BUFFER_SEED to appear in some IDL PDA `seeds`", + ); +} diff --git a/programs/settlement/tests/idl/parse_json.rs b/programs/settlement/tests/idl/parse_json.rs new file mode 100644 index 00000000..16e2c436 --- /dev/null +++ b/programs/settlement/tests/idl/parse_json.rs @@ -0,0 +1,113 @@ +//! Readers for the checked-in IDL, `programs/settlement/idl/cow_settlement.json`. +//! +//! Accessors panic naming the entry that went missing or came back the wrong +//! shape, so a malformed IDL fails where it's read rather than where it's +//! compared. + +use std::sync::LazyLock; + +use serde_json::Value; + +use crate::parse_rust::normalize_doc; + +pub const IDL_JSON: &str = include_str!("../../idl/cow_settlement.json"); +pub const SCHEMA_JSON: &str = include_str!("../../idl/schema/idl-spec-v0.1.0.json"); + +pub static IDL: LazyLock = + LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); + +/// The top-level `name[]` array, e.g. `instructions` or `types`. +pub fn section(name: &str) -> &'static [Value] { + IDL[name] + .as_array() + .unwrap_or_else(|| panic!("IDL {name}[] must be an array")) +} + +/// The `section[]` entry named `name`, if the IDL declares one. +pub fn find_item(section_name: &str, name: &str) -> Option<&'static Value> { + section(section_name) + .iter() + .find(|item| item["name"] == name) +} + +/// An entry's `docs`, one [`normalize_doc`]d string per entry, so they compare +/// directly against what [`crate::parse_rust::docs`] reports. Absent docs read +/// as none at all; the caller compares against the Rust source to decide +/// whether that's wrong. +pub fn docs(item: &Value, context: &str) -> Vec { + let Some(docs) = item.get("docs") else { + return Vec::new(); + }; + docs.as_array() + .unwrap_or_else(|| panic!("docs for {context} should be an array")) + .iter() + .map(|doc| { + let doc = doc + .as_str() + .unwrap_or_else(|| panic!("doc entry for {context} should be a string")); + normalize_doc(&[doc]) + }) + .collect() +} + +/// An entry's `discriminator` bytes. +pub fn discriminator(item: &Value, context: &str) -> Vec { + item["discriminator"] + .as_array() + .unwrap_or_else(|| panic!("discriminator for {context} should be an array")) + .iter() + .map(|byte| { + let byte = byte + .as_u64() + .unwrap_or_else(|| panic!("discriminator byte for {context} should be a number")); + u8::try_from(byte) + .unwrap_or_else(|_| panic!("discriminator byte for {context} should fit in a u8")) + }) + .collect() +} + +/// The `(name, type)` pairs of a struct `types[]` entry, in declaration order. +pub fn struct_fields(item: &Value, context: &str) -> Vec<(String, Value)> { + item["type"]["fields"] + .as_array() + .unwrap_or_else(|| panic!("struct type {context} should have a fields array")) + .iter() + .map(|field| { + ( + field["name"] + .as_str() + .unwrap_or_else(|| panic!("field name in {context} must be a string")) + .to_string(), + field["type"].clone(), + ) + }) + .collect() +} + +/// Every `{"kind": "const"}` seed byte string anywhere below `value`. +pub fn const_seeds(value: &Value) -> Vec> { + fn collect(value: &Value, out: &mut Vec>) { + match value { + Value::Object(map) => { + if map.get("kind").and_then(Value::as_str) == Some("const") { + if let Some(Value::Array(bytes)) = map.get("value") { + let decoded: Vec = bytes + .iter() + .map(|b| b.as_u64().expect("seed byte must be a number") as u8) + .collect(); + out.push(decoded); + } + } + for v in map.values() { + collect(v, out); + } + } + Value::Array(arr) => arr.iter().for_each(|v| collect(v, out)), + _ => {} + } + } + + let mut found = Vec::new(); + collect(value, &mut found); + found +} diff --git a/programs/settlement/tests/idl/parse_rust.rs b/programs/settlement/tests/idl/parse_rust.rs new file mode 100644 index 00000000..950aeacc --- /dev/null +++ b/programs/settlement/tests/idl/parse_rust.rs @@ -0,0 +1,221 @@ +//! Readers for the Rust sources the IDL describes. +//! +//! Everything here goes through `syn`, panicking with the source path when the +//! item the IDL claims to describe isn't there to compare against. + +use serde_json::{json, Value}; + +/// A Rust source file, compiled in so the tests parse the same text the +/// program does. +pub struct Source { + /// Repo-relative path, for panic messages. + display: &'static str, + text: &'static str, +} + +pub const INTERFACE_LIB_RS: Source = Source { + display: "interface/src/lib.rs", + text: include_str!("../../../../interface/src/lib.rs"), +}; + +pub const INTENT_RS: Source = Source { + display: "interface/src/data/intent.rs", + text: include_str!("../../../../interface/src/data/intent.rs"), +}; + +pub const ORDER_RS: Source = Source { + display: "interface/src/data/order.rs", + text: include_str!("../../../../interface/src/data/order.rs"), +}; + +pub const STATE_RS: Source = Source { + display: "interface/src/data/state.rs", + text: include_str!("../../../../interface/src/data/state.rs"), +}; + +impl Source { + fn parse(&self) -> syn::File { + syn::parse_file(self.text) + .unwrap_or_else(|err| panic!("{} must parse: {err}", self.display)) + } + + /// The `enum name` this file declares. + pub fn find_enum(&self, name: &str) -> syn::ItemEnum { + self.parse() + .items + .into_iter() + .find_map(|item| match item { + syn::Item::Enum(e) if e.ident == name => Some(e), + _ => None, + }) + .unwrap_or_else(|| panic!("{name} enum must exist in {}", self.display)) + } + + /// The `struct name` this file declares. + pub fn find_struct(&self, name: &str) -> syn::ItemStruct { + self.parse() + .items + .into_iter() + .find_map(|item| match item { + syn::Item::Struct(s) if s.ident == name => Some(s), + _ => None, + }) + .unwrap_or_else(|| panic!("struct {name} not found in {}", self.display)) + } +} + +/// The variant of `rust_enum` whose discriminant is `byte`. +pub fn variant_by_discriminant(rust_enum: &syn::ItemEnum, byte: u8) -> &syn::Variant { + rust_enum + .variants + .iter() + .find(|variant| discriminant(variant) == u64::from(byte)) + .unwrap_or_else(|| { + panic!( + "{} must have a variant with discriminant {byte}", + rust_enum.ident + ) + }) +} + +/// Translates a Rust field type into the IDL spec's type grammar, so field +/// types can be compared as JSON. Panics on anything the program's data types +/// don't currently use. +pub fn type_to_idl(ty: &syn::Type, context: &str) -> Value { + match ty { + syn::Type::Path(path) => { + let ident = path + .path + .get_ident() + .unwrap_or_else(|| panic!("{context}: expected a plain type name")) + .to_string(); + match ident.as_str() { + "Pubkey" => json!("pubkey"), + "bool" | "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" + | "i128" => json!(ident), + // Anything else is one of this crate's own types, which the IDL + // carries as its own `types[]` entry and references by name. + _ => json!({ "defined": { "name": ident } }), + } + } + syn::Type::Array(array) => { + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(len), + .. + }) = &array.len + else { + panic!("{context}: array length must be an integer literal"); + }; + let len: u64 = len.base10_parse().expect("array length must be a u64"); + json!({ "array": [type_to_idl(&array.elem, context), len] }) + } + _ => panic!("{context}: unsupported field type"), + } +} + +/// The `= N` discriminant an enum variant declares. Every enum the IDL mirrors +/// pins its wire values explicitly, so a missing or computed one is a bug. +pub fn discriminant(variant: &syn::Variant) -> u64 { + match &variant.discriminant { + Some(( + _, + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(i), + .. + }), + )) => i.base10_parse().unwrap_or_else(|err| { + panic!( + "discriminant on {} must be an unsigned integer literal: {err}", + variant.ident + ) + }), + Some(_) => panic!("unexpected non-literal discriminant on {}", variant.ident), + None => panic!("discriminant should be defined on {}", variant.ident), + } +} + +/// The `(name, type)` pairs of a struct's fields, in declaration order, with +/// each type already translated into the IDL's type grammar. +pub fn struct_fields(rust_struct: &syn::ItemStruct, context: &str) -> Vec<(String, Value)> { + rust_struct + .fields + .iter() + .map(|field| { + let name = field + .ident + .as_ref() + .unwrap_or_else(|| panic!("{context} should have named fields")) + .to_string(); + let idl_type = type_to_idl(&field.ty, &format!("{context}.{name}")); + (name, idl_type) + }) + .collect() +} + +/// Collapses doc lines into the single line the IDL carries them as, dropping +/// the backtick markup the IDL only carries in some places. Both this module +/// and [`crate::parse_json`] hand their doc text through here, so the two sides +/// are always compared in the same form. +pub fn normalize_doc>(lines: &[S]) -> String { + lines + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(" ") + .replace('`', "") +} + +/// Extract the doc text from a single attribute line in Rust +fn doc_attr_text(attr: &syn::Attribute) -> Option { + if !attr.path().is_ident("doc") { + return None; + } + let syn::Meta::NameValue(nv) = &attr.meta else { + return None; + }; + let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = &nv.value + else { + return None; + }; + // Doc comments start with spaces. We want to remove that, + // but we want to keep extra alignment spacing if present. + let doc = s.value(); + Some(doc.strip_prefix(' ').unwrap_or(&doc).to_string()) +} + +/// An item's doc comment, one [`normalize_doc`]d string per paragraph, with +/// blank doc lines separating the paragraphs. +pub fn docs(attrs: &[syn::Attribute]) -> Vec { + let mut paragraphs = Vec::new(); + let mut paragraph: Vec = Vec::new(); + for line in attrs.iter().filter_map(doc_attr_text) { + if line.is_empty() { + if !paragraph.is_empty() { + paragraphs.push(normalize_doc(&std::mem::take(&mut paragraph))); + } + } else { + paragraph.push(line); + } + } + if !paragraph.is_empty() { + paragraphs.push(normalize_doc(¶graph)); + } + paragraphs +} + +/// [`docs`] of the variant with discriminant `byte` in whichever discriminator +/// enum backs the IDL's `element_type[]` section. +pub fn discriminator_variant_docs(element_type: &str, byte: u8) -> Vec { + let enum_name = match element_type { + "instructions" => "SettlementInstruction", + "accounts" => "SettlementAccount", + other => panic!("no discriminator enum backs IDL {other}[]"), + }; + + let rust_enum = INTERFACE_LIB_RS.find_enum(enum_name); + + docs(&variant_by_discriminant(&rust_enum, byte).attrs) +} From 3190d0ecd84f19983488c8f6e75c014dc4fae73b Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:48:12 +0900 Subject: [PATCH 09/14] switch to an enum to make it more clear what a type is in the idl context --- programs/settlement/tests/idl/main.rs | 24 ++++++---- programs/settlement/tests/idl/parse_json.rs | 52 +++++++++++++++++---- programs/settlement/tests/idl/parse_rust.rs | 12 +++-- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/programs/settlement/tests/idl/main.rs b/programs/settlement/tests/idl/main.rs index fea546ab..18c644e9 100644 --- a/programs/settlement/tests/idl/main.rs +++ b/programs/settlement/tests/idl/main.rs @@ -12,7 +12,7 @@ mod parse_rust; use std::collections::BTreeSet; -use parse_json::IDL; +use parse_json::{Section, IDL}; use serde_json::Value; use settlement_interface::{ pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, @@ -34,8 +34,8 @@ fn pascal_to_snake(s: &str) -> String { out } -fn confirm_idl_match(byte: u8, element_type: &str, idl_name: &str) { - let idl_element = parse_json::find_item(element_type, idl_name) +fn confirm_idl_match(byte: u8, section: Section, idl_name: &str) { + let idl_element = parse_json::find_item(section, idl_name) .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); // confirm the discriminator matches @@ -51,7 +51,7 @@ fn confirm_idl_match(byte: u8, element_type: &str, idl_name: &str) { // grammar can't express (`begin_settle`'s dynamically-shaped tail, for // one), which have no business being in the program's own docs. let idl_docs = parse_json::docs(idl_element, idl_name); - let rust_docs = parse_rust::discriminator_variant_docs(element_type, byte); + let rust_docs = parse_rust::discriminator_variant_docs(section, byte); let mut unmatched_idl_docs = idl_docs.iter(); for rust_doc in &rust_docs { @@ -110,7 +110,11 @@ fn idl_conforms_to_official_schema() { fn idl_matches_instruction_discriminators() { for byte in 0u8..=255 { if let Ok(ix) = SettlementInstruction::try_from(byte) { - confirm_idl_match(byte, "instructions", &pascal_to_snake(&format!("{ix:?}"))); + confirm_idl_match( + byte, + Section::Instructions, + &pascal_to_snake(&format!("{ix:?}")), + ); } } } @@ -119,7 +123,7 @@ fn idl_matches_instruction_discriminators() { fn idl_matches_account_discriminators() { for byte in 0u8..=255 { if let Ok(account) = SettlementAccount::try_from(byte) { - confirm_idl_match(byte, "accounts", &format!("{account:?}")); + confirm_idl_match(byte, Section::Accounts, &format!("{account:?}")); } } } @@ -135,7 +139,7 @@ fn confirm_idl_types_entry( idl_type_name: &str, ) { // load in the idl and rust type definitions - let type_in_idl = parse_json::find_item("types", idl_type_name) + let type_in_idl = parse_json::find_item(Section::Types, idl_type_name) .unwrap_or_else(|| panic!("IDL types[] must contain {idl_type_name}")); let rust_struct = rust_source.find_struct(rust_type_name); @@ -168,8 +172,8 @@ fn idl_matches_rust_errors() { let rust_errors_type = parse_rust::INTERFACE_LIB_RS.find_enum("SettlementError"); for rust_err in &rust_errors_type.variants { - let idl_err = - parse_json::find_item("errors", &rust_err.ident.to_string()).unwrap_or_else(|| { + let idl_err = parse_json::find_item(Section::Errors, &rust_err.ident.to_string()) + .unwrap_or_else(|| { panic!( "Settlement program error {} is not defined in IDL errors[]", rust_err.ident @@ -206,7 +210,7 @@ fn idl_pda_seed_literals_match_pda_module() { .into_iter() .collect(); - let found = parse_json::const_seeds(&IDL["instructions"]); + let found = parse_json::const_seeds(parse_json::section(Section::Instructions)); // Every const seed the IDL does declare must be a real seed constant. Not // every seed constant needs to show up: `order_pda`'s canonical seed diff --git a/programs/settlement/tests/idl/parse_json.rs b/programs/settlement/tests/idl/parse_json.rs index 16e2c436..44df6478 100644 --- a/programs/settlement/tests/idl/parse_json.rs +++ b/programs/settlement/tests/idl/parse_json.rs @@ -4,7 +4,7 @@ //! shape, so a malformed IDL fails where it's read rather than where it's //! compared. -use std::sync::LazyLock; +use std::{fmt, sync::LazyLock}; use serde_json::Value; @@ -16,15 +16,45 @@ pub const SCHEMA_JSON: &str = include_str!("../../idl/schema/idl-spec-v0.1.0.jso pub static IDL: LazyLock = LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); -/// The top-level `name[]` array, e.g. `instructions` or `types`. -pub fn section(name: &str) -> &'static [Value] { - IDL[name] +/// A top-level array of the IDL. Naming the sections the tests read as a type +/// keeps a mistyped section from reading as a missing entry. +#[derive(Clone, Copy)] +pub enum Section { + Instructions, + Accounts, + Types, + Errors, +} + +impl Section { + /// The IDL key this section lives under. + pub fn as_str(self) -> &'static str { + match self { + Self::Instructions => "instructions", + Self::Accounts => "accounts", + Self::Types => "types", + Self::Errors => "errors", + } + } +} + +impl fmt::Display for Section { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The top-level `section[]` array. +pub fn section(section: Section) -> &'static [Value] { + IDL[section.as_str()] .as_array() - .unwrap_or_else(|| panic!("IDL {name}[] must be an array")) + .unwrap_or_else(|| panic!("IDL {section}[] must be an array")) } -/// The `section[]` entry named `name`, if the IDL declares one. -pub fn find_item(section_name: &str, name: &str) -> Option<&'static Value> { +/// Under the array for `section_name`, find the object where the `name` matches the given value. +/// Since each IDL section follows the same pattern where each section is an array of objects which contain a field `name`, this function +/// is useful for finding just about any item we need in the IDL file. +pub fn find_item(section_name: Section, name: &str) -> Option<&'static Value> { section(section_name) .iter() .find(|item| item["name"] == name) @@ -84,8 +114,8 @@ pub fn struct_fields(item: &Value, context: &str) -> Vec<(String, Value)> { .collect() } -/// Every `{"kind": "const"}` seed byte string anywhere below `value`. -pub fn const_seeds(value: &Value) -> Vec> { +/// Every `{"kind": "const"}` seed byte string anywhere below `values`. +pub fn const_seeds(values: &[Value]) -> Vec> { fn collect(value: &Value, out: &mut Vec>) { match value { Value::Object(map) => { @@ -108,6 +138,8 @@ pub fn const_seeds(value: &Value) -> Vec> { } let mut found = Vec::new(); - collect(value, &mut found); + for value in values { + collect(value, &mut found); + } found } diff --git a/programs/settlement/tests/idl/parse_rust.rs b/programs/settlement/tests/idl/parse_rust.rs index 950aeacc..f3bef5a5 100644 --- a/programs/settlement/tests/idl/parse_rust.rs +++ b/programs/settlement/tests/idl/parse_rust.rs @@ -5,6 +5,8 @@ use serde_json::{json, Value}; +use crate::parse_json::Section; + /// A Rust source file, compiled in so the tests parse the same text the /// program does. pub struct Source { @@ -207,11 +209,11 @@ pub fn docs(attrs: &[syn::Attribute]) -> Vec { } /// [`docs`] of the variant with discriminant `byte` in whichever discriminator -/// enum backs the IDL's `element_type[]` section. -pub fn discriminator_variant_docs(element_type: &str, byte: u8) -> Vec { - let enum_name = match element_type { - "instructions" => "SettlementInstruction", - "accounts" => "SettlementAccount", +/// enum backs the given IDL `section`. +pub fn discriminator_variant_docs(section: Section, byte: u8) -> Vec { + let enum_name = match section { + Section::Instructions => "SettlementInstruction", + Section::Accounts => "SettlementAccount", other => panic!("no discriminator enum backs IDL {other}[]"), }; From 70050939d11969b0d42b417da750ba37c8785883 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:59:06 +0900 Subject: [PATCH 10/14] update to the latest on the idl side --- programs/settlement/idl/cow_settlement.json | 258 +++++++++++++++++++- 1 file changed, 247 insertions(+), 11 deletions(-) diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index 9d3c7a3f..635f519e 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -1,8 +1,8 @@ { - "address": "MooohhPEAAHwAwEozL7JPEmnDvaahuUpccYN4Yb8ccK", + "address": "J516Mv7YvvvJyMvNEca8tWNTJyDHbFpzwDZD96BNfR3w", "metadata": { "name": "cow_settlement", - "version": "0.1.0", + "version": "0.2.0", "spec": "0.1.0", "description": "CoW Protocol settlement program. IDL may not be able to generate typical of Anchor programs, so please be careful to read the documentation on the function you are using.", "repository": "https://github.com/cowprotocol/solana-programs" @@ -47,7 +47,7 @@ 118, 48, 46, - 49, + 50, 32, 32, 32, @@ -66,10 +66,17 @@ } ], "args": [ + { + "name": "manager", + "docs": [ + "Recorded verbatim in the state PDA's body as the initial holder of the Manager role: the account authorized to add and remove solvers and to transfer any role." + ], + "type": "pubkey" + }, { "name": "reclaim_authority", "docs": [ - "Recorded verbatim in the state PDA's body as SettlementState.reclaim_authority." + "Recorded verbatim in the state PDA's body as the initial holder of the ReclaimAuthority role: the account authorized to close buffer accounts and reclaim their rent." ], "type": "pubkey" } @@ -121,7 +128,7 @@ 118, 48, 46, - 49, + 50, 32, 32, 32, @@ -210,7 +217,7 @@ "name": "begin_settle", "docs": [ "Pulls funds for a batch of orders. Must be paired in the same transaction with a FinalizeSettle at `finalize_ix_index`.", - "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index, auction_id) is represented as typed arguments. After it, the real wire format is `[order_count: u8][bump; order_count][transfer_count; order_count][amount: u64 LE; sum(transfer_count)]`. Please check the rust documentation for more information." + "IDL LIMITATION: only the fixed-size prefix (finalize_ix_index, auction_id) is represented as typed arguments. After it, the real wire format is `[order_count: u8][transfer_count; order_count][amount: u64 LE; sum(transfer_count)]`. Please check the rust documentation for more information." ], "discriminator": [ 0 @@ -251,7 +258,8 @@ { "name": "finalize_settle", "docs": [ - "Validates that a BeginSettle at `begin_ix_index` exists and points back at this instruction. Must not be called via CPI." + "Validates that a BeginSettle at `begin_ix_index` exists and points back at this instruction. Must not be called via CPI.", + "IDL LIMITATION: only the fixed-size prefix (begin_ix_index) is represented as a typed argument. After it, the real wire format is `[bump: u8; push_count][amount: u64 LE; push_count]`, where each bump is the canonical bump of the push's source buffer PDA. Please check the rust documentation for more information." ], "discriminator": [ 1 @@ -296,7 +304,7 @@ "name": "order_pda", "writable": true, "docs": [ - "The order PDA to close." + "The order PDA to close. Its canonical bump is read out of the stored OrderAccount body, so no bump argument is needed." ] }, { @@ -307,12 +315,200 @@ ] } ], + "args": [] + }, + { + "name": "reclaim_buffer", + "docs": [ + "Closes one or more buffer PDAs and sends each closed buffer's rent lamports to a reclaim_recipient of the caller's choosing. Only the current holder of the ReclaimAuthority role recorded in the state PDA may authorize this. A buffer that still holds tokens is skipped, not closed, and the instruction still succeeds.", + "IDL LIMITATION: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (ReclaimBuffer rejects zero buffers). IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0).", + "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]; mint_i is passed only so that derivation can be checked on-chain." + ], + "discriminator": [ + 6 + ], + "accounts": [ + { + "name": "state_pda", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 116, + 116, + 108, + 101, + 109, + 101, + 110, + 116, + 32, + 118, + 48, + 46, + 50, + 32, + 32, + 32, + 32 + ] + } + ] + }, + "docs": [ + "Must be the canonical state PDA; holds the reclaim_authority the signer is checked against, and is the buffers' SPL token authority. Canonical PDA seeded by SETTLEMENT_SEED alone. SETTLEMENT_SEED is the ASCII prefix \"settlement v\" followed by the program's major.minor version, right-padded with spaces to a fixed 19 bytes; the fixed width prevents one version's seeds from being a prefix of another's. The const bytes below are authoritative and change on every minor version bump." + ] + }, + { + "name": "reclaim_authority", + "signer": true, + "docs": [ + "Must match the reclaim_authority address recorded in the state PDA." + ] + }, + { + "name": "reclaim_recipient", + "writable": true, + "docs": [ + "Receives the rent lamports of every closed buffer." + ] + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "buffer_pda_0", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 116, + 116, + 108, + 101, + 109, + 101, + 110, + 116, + 32, + 118, + 48, + 46, + 50, + 32, + 32, + 32, + 32 + ] + }, + { + "kind": "account", + "path": "mint_0" + }, + { + "kind": "const", + "value": [ + 98, + 117, + 102, + 102, + 101, + 114 + ] + } + ] + }, + "docs": [ + "Guaranteed present: ReclaimBuffer rejects an instruction with zero buffers." + ] + }, + { + "name": "mint_0", + "docs": [ + "Guaranteed present: ReclaimBuffer rejects an instruction with zero buffers." + ] + } + ], + "args": [] + }, + { + "name": "transfer_authority", + "docs": [ + "Transfers one Role stored in the state PDA to another account in a single step: the signer names the new holder and the role's holder is updated immediately.", + "The signer must be either the manager or the current holder of the role being transferred." + ], + "discriminator": [ + 7 + ], + "accounts": [ + { + "name": "signer", + "signer": true, + "docs": [ + "Authorizes the transfer; must be the manager or the current holder of `role`." + ] + }, + { + "name": "state_pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 116, + 116, + 108, + 101, + 109, + 101, + 110, + 116, + 32, + 118, + 48, + 46, + 50, + 32, + 32, + 32, + 32 + ] + } + ] + }, + "docs": [ + "Canonical PDA seeded by SETTLEMENT_SEED alone. SETTLEMENT_SEED is the ASCII prefix \"settlement v\" followed by the program's major.minor version, right-padded with spaces to a fixed 19 bytes; the fixed width prevents one version's seeds from being a prefix of another's. The const bytes below are authoritative and change on every minor version bump." + ] + } + ], "args": [ { - "name": "bump", - "type": "u8", + "name": "role", + "type": { + "defined": { + "name": "Role" + } + }, + "docs": [ + "The role to transfer, as its single wire discriminator byte." + ] + }, + { + "name": "new_authority", + "type": "pubkey", "docs": [ - "order_pda's canonical bump, used to prove it's the canonical order PDA for the intent it stores." + "Becomes the holder of `role` once the instruction succeeds." ] } ] @@ -341,6 +537,10 @@ "type": { "kind": "struct", "fields": [ + { + "name": "bump", + "type": "u8" + }, { "name": "cancelled", "type": "bool" @@ -376,6 +576,10 @@ "type": { "kind": "struct", "fields": [ + { + "name": "manager", + "type": "pubkey" + }, { "name": "reclaim_authority", "type": "pubkey" @@ -454,6 +658,23 @@ } ] } + }, + { + "name": "Role", + "docs": [ + "A transferable authority stored in the state PDA. The variant index is the single wire byte that selects the role in transfer_authority." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Manager" + }, + { + "name": "ReclaimAuthority" + } + ] + } } ], "errors": [ @@ -616,6 +837,21 @@ "code": 31, "name": "ReclaimRecipientMismatch", "msg": "ReclaimOrder's reclaim_recipient account doesn't match the created_by address recorded in the order." + }, + { + "code": 32, + "name": "ReclaimAuthorityMismatch", + "msg": "ReclaimBuffer's reclaim_authority account isn't a signer, or doesn't match the reclaim_authority address recorded in the settlement state PDA." + }, + { + "code": 33, + "name": "ReclaimBufferNotCanonical", + "msg": "A ReclaimBuffer buffer_pda doesn't sit at the canonical buffer PDA derived from its paired mint." + }, + { + "code": 34, + "name": "UnauthorizedAuthorityTransfer", + "msg": "TransferAuthority's signer is neither the manager nor the current holder of the role being transferred, so it may not transfer it." } ] } From ba596f8eabba66233683ce5f5ccc07b9387ed396 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:02:35 +0900 Subject: [PATCH 11/14] add test verifying the idl version matches cargo pkg --- programs/settlement/tests/idl/main.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/programs/settlement/tests/idl/main.rs b/programs/settlement/tests/idl/main.rs index 72eab564..b491710a 100644 --- a/programs/settlement/tests/idl/main.rs +++ b/programs/settlement/tests/idl/main.rs @@ -12,12 +12,12 @@ mod parse_rust; use std::collections::BTreeSet; -use parse_json::{Section, IDL}; -use serde_json::Value; use cow_settlement_interface::{ pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, SettlementAccount, SettlementInstruction, }; +use parse_json::{Section, IDL}; +use serde_json::Value; fn pascal_to_snake(s: &str) -> String { let mut out = String::new(); @@ -93,6 +93,18 @@ fn idl_address_matches_declared_program_id() { ); } +#[test] +fn idl_version_matches_cargo_package_version() { + assert_eq!( + IDL["metadata"]["version"] + .as_str() + .expect("metadata.version must be a string"), + env!("CARGO_PKG_VERSION"), + "IDL `metadata.version` must match the settlement program's cargo package version; \ + bumping the minor version also moves every PDA, so a stale value here hides that" + ); +} + #[test] fn idl_conforms_to_official_schema() { let schema: Value = From f4cf0438831f179e29f575cd3fbd53d2333a77a6 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:14:56 +0900 Subject: [PATCH 12/14] improve confirm_idl_match params naming --- programs/settlement/tests/idl/main.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/programs/settlement/tests/idl/main.rs b/programs/settlement/tests/idl/main.rs index b491710a..f6cf2552 100644 --- a/programs/settlement/tests/idl/main.rs +++ b/programs/settlement/tests/idl/main.rs @@ -34,14 +34,14 @@ fn pascal_to_snake(s: &str) -> String { out } -fn confirm_idl_match(byte: u8, section: Section, idl_name: &str) { - let idl_element = parse_json::find_item(section, idl_name) +fn confirm_idl_match(idl_section: Section, idl_name: &str, discriminator_byte: u8) { + let idl_element = parse_json::find_item(idl_section, idl_name) .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); // confirm the discriminator matches assert_eq!( parse_json::discriminator(idl_element, idl_name), - vec![byte], + vec![discriminator_byte], "IDL discriminator for {idl_name} should be the single byte the code uses" ); @@ -51,7 +51,7 @@ fn confirm_idl_match(byte: u8, section: Section, idl_name: &str) { // grammar can't express (`begin_settle`'s dynamically-shaped tail, for // one), which have no business being in the program's own docs. let idl_docs = parse_json::docs(idl_element, idl_name); - let rust_docs = parse_rust::discriminator_variant_docs(section, byte); + let rust_docs = parse_rust::discriminator_variant_docs(idl_section, discriminator_byte); let mut unmatched_idl_docs = idl_docs.iter(); for rust_doc in &rust_docs { @@ -123,9 +123,9 @@ fn idl_matches_instruction_discriminators() { for byte in 0u8..=255 { if let Ok(ix) = SettlementInstruction::try_from(byte) { confirm_idl_match( - byte, Section::Instructions, &pascal_to_snake(&format!("{ix:?}")), + byte, ); } } @@ -135,7 +135,7 @@ fn idl_matches_instruction_discriminators() { fn idl_matches_account_discriminators() { for byte in 0u8..=255 { if let Ok(account) = SettlementAccount::try_from(byte) { - confirm_idl_match(byte, Section::Accounts, &format!("{account:?}")); + confirm_idl_match(Section::Accounts, &format!("{account:?}"), byte); } } } From af8cfd32bd579b91976af5d1794b0ef45ae503a3 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:00:23 +0900 Subject: [PATCH 13/14] add more idl checks, when it was easy enough --- programs/settlement/idl/cow_settlement.json | 7 +- programs/settlement/tests/idl/main.rs | 189 +++++++++++++++++++- programs/settlement/tests/idl/parse_json.rs | 59 +++++- programs/settlement/tests/idl/parse_rust.rs | 133 ++++++++++++-- 4 files changed, 359 insertions(+), 29 deletions(-) diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index 635f519e..60865a4f 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -647,6 +647,9 @@ }, { "name": "OrderKind", + "docs": [ + "Direction of the trade." + ], "type": { "kind": "enum", "variants": [ @@ -662,7 +665,9 @@ { "name": "Role", "docs": [ - "A transferable authority stored in the state PDA. The variant index is the single wire byte that selects the role in transfer_authority." + "A transferable authority stored in the state PDA.", + "The discriminant is the wire value carried by the authority-transfer", + "instruction (see transfer_authority)." ], "type": { "kind": "enum", diff --git a/programs/settlement/tests/idl/main.rs b/programs/settlement/tests/idl/main.rs index f6cf2552..cea64d25 100644 --- a/programs/settlement/tests/idl/main.rs +++ b/programs/settlement/tests/idl/main.rs @@ -17,7 +17,7 @@ use cow_settlement_interface::{ SettlementAccount, SettlementInstruction, }; use parse_json::{Section, IDL}; -use serde_json::Value; +use serde_json::{json, Value}; fn pascal_to_snake(s: &str) -> String { let mut out = String::new(); @@ -109,6 +109,19 @@ fn idl_version_matches_cargo_package_version() { fn idl_conforms_to_official_schema() { let schema: Value = serde_json::from_str(parse_json::SCHEMA_JSON).expect("schema should be valid JSON"); + + // The bundled schema has to be the one `metadata.spec` claims to follow; + // validating against some other spec's schema would prove nothing about + // the version the IDL advertises. + let spec = IDL["metadata"]["spec"] + .as_str() + .expect("metadata.spec must be a string"); + let schema_id = schema["$id"].as_str().expect("schema $id must be a string"); + assert!( + schema_id.ends_with(&format!("v{spec}.json")), + "IDL `metadata.spec` is {spec} but the schema it's validated against is {schema_id}" + ); + let validator = jsonschema::validator_for(&schema).expect("schema should compile"); let errors: Vec = validator.iter_errors(&IDL).map(|e| e.to_string()).collect(); assert!( @@ -118,8 +131,22 @@ fn idl_conforms_to_official_schema() { ); } +/// Asserts `section[]` holds nothing beyond the `matched` entries a test just +/// cross-checked against the Rust source. Every check reads the Rust source and +/// looks for what it found in the IDL, never the other way around, so without +/// this an entry the IDL invented, or one a rename left behind, is never looked +/// at by anything. +fn confirm_no_extra_idl_entries(idl_section: Section, matched: usize) { + assert_eq!( + parse_json::section(idl_section).len(), + matched, + "IDL {idl_section}[] carries entries with no counterpart in the Rust source" + ); +} + #[test] fn idl_matches_instruction_discriminators() { + let mut matched = 0; for byte in 0u8..=255 { if let Ok(ix) = SettlementInstruction::try_from(byte) { confirm_idl_match( @@ -127,17 +154,22 @@ fn idl_matches_instruction_discriminators() { &pascal_to_snake(&format!("{ix:?}")), byte, ); + matched += 1; } } + confirm_no_extra_idl_entries(Section::Instructions, matched); } #[test] fn idl_matches_account_discriminators() { + let mut matched = 0; for byte in 0u8..=255 { if let Ok(account) = SettlementAccount::try_from(byte) { confirm_idl_match(Section::Accounts, &format!("{account:?}"), byte); + matched += 1; } } + confirm_no_extra_idl_entries(Section::Accounts, matched); } /// Cross-checks one IDL `types[]` entry against the Rust struct it describes. @@ -172,11 +204,68 @@ fn confirm_idl_types_entry( ); } +/// Cross-checks one IDL `types[]` entry against the Rust enum it describes. +/// +/// The spec's enum variants carry a name and nothing else: no discriminant, +/// since a variant's index is its wire byte, and nowhere to put docs. So the +/// variant names in order, plus the type's own docs, are the whole comparison. +fn confirm_idl_types_enum(rust_source: &parse_rust::Source, name: &str) { + let type_in_idl = parse_json::find_item(Section::Types, name) + .unwrap_or_else(|| panic!("IDL types[] must contain {name}")); + let rust_enum = rust_source.find_enum(name); + + // docs are joined and compared as one block, as they are for structs + assert_eq!( + parse_json::docs(type_in_idl, name).join(" "), + parse_rust::docs(&rust_enum.attrs).join(" "), + "documentation between rust and IDL types should be the same for {name}" + ); + + assert_eq!( + parse_json::enum_variants(type_in_idl, name), + parse_rust::enum_variants(&rust_enum), + "{name}'s IDL variants must match the Rust enum {name} in name and order" + ); + + // Position is all the IDL can say about a variant's wire value, so a Rust + // variant that pins its own discriminant has to agree with its index. + for (index, variant) in rust_enum.variants.iter().enumerate() { + let index = u64::try_from(index).expect("variant index should fit in a u64"); + if let Some(declared) = parse_rust::declared_discriminant(variant) { + assert_eq!( + declared, index, + "{name}::{} declares discriminant {declared} but sits at index {index}; the IDL \ + can only express a variant's wire value as its position", + variant.ident + ); + } + } +} + #[test] fn idl_matches_rust_types() { - confirm_idl_types_entry(&parse_rust::INTENT_RS, "OrderIntent", "OrderIntent"); - confirm_idl_types_entry(&parse_rust::ORDER_RS, "OrderAccount", "OrderAccount"); - confirm_idl_types_entry(&parse_rust::STATE_RS, "StateAccount", "SettlementState"); + // `(source, Rust name, IDL name)` of the struct types, and + // `(source, name)` of the enum types. Everything the IDL defines has to be + // listed here; the count is what catches a `types[]` entry the program has + // no definition for. + let structs = [ + (&parse_rust::INTENT_RS, "OrderIntent", "OrderIntent"), + (&parse_rust::ORDER_RS, "OrderAccount", "OrderAccount"), + (&parse_rust::STATE_RS, "StateAccount", "SettlementState"), + ]; + let enums = [ + (&parse_rust::INTENT_RS, "OrderKind"), + (&parse_rust::INTERFACE_LIB_RS, "Role"), + ]; + + for (rust_source, rust_type_name, idl_type_name) in structs { + confirm_idl_types_entry(rust_source, rust_type_name, idl_type_name); + } + for (rust_source, name) in enums { + confirm_idl_types_enum(rust_source, name); + } + + confirm_no_extra_idl_entries(Section::Types, structs.len() + enums.len()); } #[test] @@ -214,6 +303,98 @@ fn idl_matches_rust_errors() { rust_err.ident ); } + + confirm_no_extra_idl_entries(Section::Errors, rust_errors_type.variants.len()); +} + +/// Cross-checks one IDL instruction's `args[]` against the builder struct whose +/// fields become the instruction data. +/// +/// A builder carries the program id and the instruction's accounts too, so the +/// args are matched by name against a subset of its fields: each arg has to name +/// a real field, the two have to agree on type, and the args have to be ordered +/// the way the builder declares the fields, which is the order its +/// `From for Instruction` writes them to the wire. +fn confirm_idl_args_match( + rust_source: &parse_rust::Source, + builder_name: &str, + idl_instruction: &str, +) { + let idl_element = parse_json::find_item(Section::Instructions, idl_instruction) + .unwrap_or_else(|| panic!("IDL instructions[] must contain {idl_instruction}")); + let builder = rust_source.find_struct(builder_name); + let field_names = parse_rust::field_names(&builder, builder_name); + + let mut previous_arg: Option<(String, usize)> = None; + for (arg_name, idl_type) in parse_json::args(idl_element, idl_instruction) { + // panics naming the field if the builder has no such field at all + assert_eq!( + idl_type, + parse_rust::field_type(&builder, &arg_name, builder_name), + "{idl_instruction}'s arg {arg_name} must have the type {builder_name}.{arg_name} \ + declares" + ); + + let position = field_names + .iter() + .position(|field| *field == arg_name) + .expect("field_type panics before this on a field the builder doesn't declare"); + if let Some((previous_name, previous_position)) = &previous_arg { + assert!( + *previous_position < position, + "{idl_instruction} lists arg {arg_name} after {previous_name}, but {builder_name} \ + declares them the other way around; args[] is in wire order" + ); + } + previous_arg = Some((arg_name, position)); + } +} + +#[test] +fn idl_matches_instruction_args() { + confirm_idl_args_match(&parse_rust::INITIALIZE_RS, "Initialize", "initialize"); + confirm_idl_args_match(&parse_rust::BEGIN_SETTLE_RS, "BeginSettle", "begin_settle"); + confirm_idl_args_match( + &parse_rust::FINALIZE_SETTLE_RS, + "FinalizeSettle", + "finalize_settle", + ); + confirm_idl_args_match( + &parse_rust::TRANSFER_AUTHORITY_RS, + "TransferAuthority", + "transfer_authority", + ); + + // `create_order` is the one instruction whose builder doesn't mirror its + // arg: `CreateOrder` takes the intent already encoded, as + // `intent_bytes: [u8; EncodedOrderIntent::SIZE]`, where the IDL names the + // decoded type. That the two describe the same bytes is what + // `idl_matches_rust_types` checks of `OrderIntent` itself. + let create_order = parse_json::find_item(Section::Instructions, "create_order") + .expect("IDL instructions[] must contain create_order"); + assert_eq!( + parse_json::args(create_order, "create_order"), + vec![( + "intent".to_string(), + json!({ "defined": { "name": "OrderIntent" } }) + )], + "create_order takes exactly one arg, the encoded OrderIntent" + ); + let builder = parse_rust::CREATE_ORDER_RS.find_struct("CreateOrder"); + assert!( + parse_rust::field_names(&builder, "CreateOrder").contains(&"intent_bytes".to_string()), + "CreateOrder must still carry the encoded intent that create_order's `intent` arg describes" + ); + + // Instructions whose data is nothing but the discriminator byte. + for idl_instruction in ["create_buffer", "reclaim_order", "reclaim_buffer"] { + let idl_element = parse_json::find_item(Section::Instructions, idl_instruction) + .unwrap_or_else(|| panic!("IDL instructions[] must contain {idl_instruction}")); + assert!( + parse_json::args(idl_element, idl_instruction).is_empty(), + "{idl_instruction} carries no instruction data beyond its discriminator" + ); + } } #[test] diff --git a/programs/settlement/tests/idl/parse_json.rs b/programs/settlement/tests/idl/parse_json.rs index 44df6478..f0532cad 100644 --- a/programs/settlement/tests/idl/parse_json.rs +++ b/programs/settlement/tests/idl/parse_json.rs @@ -96,24 +96,65 @@ pub fn discriminator(item: &Value, context: &str) -> Vec { .collect() } -/// The `(name, type)` pairs of a struct `types[]` entry, in declaration order. -pub fn struct_fields(item: &Value, context: &str) -> Vec<(String, Value)> { - item["type"]["fields"] - .as_array() - .unwrap_or_else(|| panic!("struct type {context} should have a fields array")) +/// The `(name, type)` pairs of an array of `{"name", "type"}` objects, in +/// declaration order. `types[]` fields and `instructions[]` args share this +/// shape. +fn name_type_pairs(entries: &[Value], context: &str) -> Vec<(String, Value)> { + entries .iter() - .map(|field| { + .map(|entry| { ( - field["name"] + entry["name"] .as_str() - .unwrap_or_else(|| panic!("field name in {context} must be a string")) + .unwrap_or_else(|| panic!("entry name in {context} must be a string")) .to_string(), - field["type"].clone(), + entry["type"].clone(), ) }) .collect() } +/// The `(name, type)` pairs of a struct `types[]` entry, in declaration order. +pub fn struct_fields(item: &Value, context: &str) -> Vec<(String, Value)> { + name_type_pairs( + item["type"]["fields"] + .as_array() + .unwrap_or_else(|| panic!("struct type {context} should have a fields array")), + context, + ) +} + +/// The `(name, type)` pairs of an `instructions[]` entry's `args`, in wire +/// order. An absent `args` reads as no arguments at all, which is how the IDL +/// spells an instruction whose data is just the discriminator. +pub fn args(item: &Value, context: &str) -> Vec<(String, Value)> { + let Some(args) = item.get("args") else { + return Vec::new(); + }; + name_type_pairs( + args.as_array() + .unwrap_or_else(|| panic!("args for {context} should be an array")), + context, + ) +} + +/// The variant names of an enum `types[]` entry, in declaration order, which is +/// the order the wire discriminant counts in. The spec's `IdlEnumVariant` has +/// nowhere to put per-variant docs, so there's nothing else here to compare. +pub fn enum_variants(item: &Value, context: &str) -> Vec { + item["type"]["variants"] + .as_array() + .unwrap_or_else(|| panic!("enum type {context} should have a variants array")) + .iter() + .map(|variant| { + variant["name"] + .as_str() + .unwrap_or_else(|| panic!("variant name in {context} must be a string")) + .to_string() + }) + .collect() +} + /// Every `{"kind": "const"}` seed byte string anywhere below `values`. pub fn const_seeds(values: &[Value]) -> Vec> { fn collect(value: &Value, out: &mut Vec>) { diff --git a/programs/settlement/tests/idl/parse_rust.rs b/programs/settlement/tests/idl/parse_rust.rs index f3bef5a5..d1874272 100644 --- a/programs/settlement/tests/idl/parse_rust.rs +++ b/programs/settlement/tests/idl/parse_rust.rs @@ -35,6 +35,31 @@ pub const STATE_RS: Source = Source { text: include_str!("../../../../interface/src/data/state.rs"), }; +pub const INITIALIZE_RS: Source = Source { + display: "interface/src/instruction/initialize.rs", + text: include_str!("../../../../interface/src/instruction/initialize.rs"), +}; + +pub const CREATE_ORDER_RS: Source = Source { + display: "interface/src/instruction/create_order.rs", + text: include_str!("../../../../interface/src/instruction/create_order.rs"), +}; + +pub const BEGIN_SETTLE_RS: Source = Source { + display: "interface/src/instruction/settle/begin.rs", + text: include_str!("../../../../interface/src/instruction/settle/begin.rs"), +}; + +pub const FINALIZE_SETTLE_RS: Source = Source { + display: "interface/src/instruction/settle/finalize.rs", + text: include_str!("../../../../interface/src/instruction/settle/finalize.rs"), +}; + +pub const TRANSFER_AUTHORITY_RS: Source = Source { + display: "interface/src/instruction/transfer_authority.rs", + text: include_str!("../../../../interface/src/instruction/transfer_authority.rs"), +}; + impl Source { fn parse(&self) -> syn::File { syn::parse_file(self.text) @@ -115,9 +140,9 @@ pub fn type_to_idl(ty: &syn::Type, context: &str) -> Value { } } -/// The `= N` discriminant an enum variant declares. Every enum the IDL mirrors -/// pins its wire values explicitly, so a missing or computed one is a bug. -pub fn discriminant(variant: &syn::Variant) -> u64 { +/// The `= N` discriminant an enum variant declares, or `None` where it leans on +/// the implicit "one past the previous variant" value. +pub fn declared_discriminant(variant: &syn::Variant) -> Option { match &variant.discriminant { Some(( _, @@ -125,17 +150,24 @@ pub fn discriminant(variant: &syn::Variant) -> u64 { lit: syn::Lit::Int(i), .. }), - )) => i.base10_parse().unwrap_or_else(|err| { + )) => Some(i.base10_parse().unwrap_or_else(|err| { panic!( "discriminant on {} must be an unsigned integer literal: {err}", variant.ident ) - }), + })), Some(_) => panic!("unexpected non-literal discriminant on {}", variant.ident), - None => panic!("discriminant should be defined on {}", variant.ident), + None => None, } } +/// The `= N` discriminant an enum variant declares. The discriminator enums the +/// IDL mirrors pin their wire values explicitly, so a missing one is a bug. +pub fn discriminant(variant: &syn::Variant) -> u64 { + declared_discriminant(variant) + .unwrap_or_else(|| panic!("discriminant should be defined on {}", variant.ident)) +} + /// The `(name, type)` pairs of a struct's fields, in declaration order, with /// each type already translated into the IDL's type grammar. pub fn struct_fields(rust_struct: &syn::ItemStruct, context: &str) -> Vec<(String, Value)> { @@ -154,17 +186,88 @@ pub fn struct_fields(rust_struct: &syn::ItemStruct, context: &str) -> Vec<(Strin .collect() } +/// The names of a struct's fields, in declaration order. Unlike +/// [`struct_fields`] this translates no types, so it reads structs carrying +/// fields the IDL's type grammar has no equivalent for. +pub fn field_names(rust_struct: &syn::ItemStruct, context: &str) -> Vec { + rust_struct + .fields + .iter() + .map(|field| { + field + .ident + .as_ref() + .unwrap_or_else(|| panic!("{context} should have named fields")) + .to_string() + }) + .collect() +} + +/// One named field's type, translated into the IDL's type grammar. +pub fn field_type(rust_struct: &syn::ItemStruct, name: &str, context: &str) -> Value { + let field = rust_struct + .fields + .iter() + .find(|field| field.ident.as_ref().is_some_and(|ident| ident == name)) + .unwrap_or_else(|| panic!("{context} should have a field named {name}")); + type_to_idl(&field.ty, &format!("{context}.{name}")) +} + +/// The variant names of an enum, in declaration order. +pub fn enum_variants(rust_enum: &syn::ItemEnum) -> Vec { + rust_enum + .variants + .iter() + .map(|variant| variant.ident.to_string()) + .collect() +} + +/// Unwraps rustdoc intra-doc links to the text they display: `[`Role`](Role)` +/// reads as `Role`. The IDL has no notion of a link target, so carrying one +/// there would only be Rust markup leaking into the published interface. +fn strip_doc_links(text: &str) -> String { + /// The display text and the remainder past `[display](target)`, when `text` + /// starts with a link whose two halves nest no brackets of their own. + /// Anything else isn't a link and is left exactly as written. + fn split_link(text: &str) -> Option<(&str, &str)> { + let (display, after_display) = text.strip_prefix('[')?.split_once("](")?; + let (target, after_link) = after_display.split_once(')')?; + (!display.contains('[') && !target.contains('(')).then_some((display, after_link)) + } + + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(open) = rest.find('[') { + out.push_str(&rest[..open]); + rest = &rest[open..]; + match split_link(rest) { + Some((display, after_link)) => { + out.push_str(display); + rest = after_link; + } + None => { + out.push('['); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + /// Collapses doc lines into the single line the IDL carries them as, dropping -/// the backtick markup the IDL only carries in some places. Both this module -/// and [`crate::parse_json`] hand their doc text through here, so the two sides -/// are always compared in the same form. +/// the backtick and link markup the IDL only carries in some places. Both this +/// module and [`crate::parse_json`] hand their doc text through here, so the two +/// sides are always compared in the same form. pub fn normalize_doc>(lines: &[S]) -> String { - lines - .iter() - .map(AsRef::as_ref) - .collect::>() - .join(" ") - .replace('`', "") + strip_doc_links( + &lines + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(" ") + .replace('`', ""), + ) } /// Extract the doc text from a single attribute line in Rust From 0ae07e2df200abe2934a93223574780a2e3e626f Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:52:12 +0900 Subject: [PATCH 14/14] rethink how the testing structure works it increases the amount of code overall, but it puts us in the right trajectory to be effectively generating parts of the IDL from rust. --- programs/settlement/tests/idl/generate.rs | 337 +++++++++++++++ programs/settlement/tests/idl/main.rs | 432 +++----------------- programs/settlement/tests/idl/parse_json.rs | 186 --------- programs/settlement/tests/idl/parse_rust.rs | 164 ++++---- programs/settlement/tests/idl/superset.rs | 215 ++++++++++ 5 files changed, 682 insertions(+), 652 deletions(-) create mode 100644 programs/settlement/tests/idl/generate.rs delete mode 100644 programs/settlement/tests/idl/parse_json.rs create mode 100644 programs/settlement/tests/idl/superset.rs diff --git a/programs/settlement/tests/idl/generate.rs b/programs/settlement/tests/idl/generate.rs new file mode 100644 index 00000000..c918efa4 --- /dev/null +++ b/programs/settlement/tests/idl/generate.rs @@ -0,0 +1,337 @@ +//! Assembles the partial IDL the Rust source implies. +//! +//! Everything here reads the program's own source through [`crate::parse_rust`] +//! and emits it in the IDL spec's JSON grammar, producing a document shaped +//! exactly like `idl/cow_settlement.json` but carrying only the facts the +//! source pins. Whatever the source can't state — an instruction's account +//! list, the prose describing each argument, the `metadata` blurbs — is simply +//! left out, and [`crate::superset`] is what says the checked-in IDL has to +//! agree with everything that _is_ here. +//! +//! The tables below are the one place a name has to be written twice. They +//! exist because nothing in the Rust source says which file holds an +//! instruction's parsed input, which struct backs a `types[]` entry, or which +//! of an instruction's accounts the IDL derives as a PDA. + +use cow_settlement_interface::{ + pda::{buffer::BUFFER_SEED, SETTLEMENT_SEED}, + SettlementInstruction, +}; +use serde_json::{json, Map, Value}; + +use crate::parse_rust::{self, Source}; + +/// One seed of a PDA account, as the IDL spells it. +enum Seed { + /// Bytes pinned by a constant in `interface::pda`. + Const(&'static [u8]), + /// Another of the instruction's accounts, named the way the IDL names it. + Account(&'static str), +} + +impl Seed { + fn to_idl(&self) -> Value { + match self { + Self::Const(bytes) => json!({ "kind": "const", "value": bytes }), + Self::Account(path) => json!({ "kind": "account", "path": path }), + } + } +} + +/// The canonical settlement state PDA, seeded by the version-stamped prefix +/// alone. +const STATE_PDA: &[Seed] = &[Seed::Const(SETTLEMENT_SEED)]; + +/// A per-token buffer PDA. The IDL can only declare the guaranteed index-0 +/// buffer of the unbounded run an instruction actually accepts, so the mint it +/// derives from is `mint_0`. +const BUFFER_PDA_0: &[Seed] = &[ + Seed::Const(SETTLEMENT_SEED), + Seed::Account("mint_0"), + Seed::Const(BUFFER_SEED), +]; + +/// What the Rust source doesn't say about one instruction. +struct Instruction { + /// The discriminator variant naming it. The IDL calls the instruction by + /// this name in `snake_case`. + variant: SettlementInstruction, + /// The file declaring `Input`, the struct [`args`] reads. + input: &'static Source, + /// The accounts the IDL declares a `pda` for, and the seeds that PDA is + /// derived from. Accounts without one aren't listed: nothing in the Rust + /// source pins the name the IDL gives them. + pda_accounts: &'static [(&'static str, &'static [Seed])], +} + +const INSTRUCTIONS: &[Instruction] = &[ + Instruction { + variant: SettlementInstruction::Initialize, + input: &parse_rust::INITIALIZE_RS, + pda_accounts: &[("state_pda", STATE_PDA)], + }, + Instruction { + variant: SettlementInstruction::CreateBuffer, + input: &parse_rust::CREATE_BUFFER_RS, + pda_accounts: &[("buffer_pda_0", BUFFER_PDA_0)], + }, + Instruction { + variant: SettlementInstruction::CreateOrder, + input: &parse_rust::CREATE_ORDER_RS, + // `order_pda`'s canonical seeds include `sha256(intent)`, which the IDL + // has no `seeds` kind for; create_order's docs say so instead. + pda_accounts: &[], + }, + Instruction { + variant: SettlementInstruction::BeginSettle, + input: &parse_rust::BEGIN_SETTLE_RS, + // `state_pda` is passed as a plain account here rather than derived: + // BeginSettle checks it against the canonical address itself. + pda_accounts: &[], + }, + Instruction { + variant: SettlementInstruction::FinalizeSettle, + input: &parse_rust::FINALIZE_SETTLE_RS, + pda_accounts: &[], + }, + Instruction { + variant: SettlementInstruction::ReclaimOrder, + input: &parse_rust::RECLAIM_ORDER_RS, + pda_accounts: &[], + }, + Instruction { + variant: SettlementInstruction::ReclaimBuffer, + input: &parse_rust::RECLAIM_BUFFER_RS, + pda_accounts: &[("state_pda", STATE_PDA), ("buffer_pda_0", BUFFER_PDA_0)], + }, + Instruction { + variant: SettlementInstruction::TransferAuthority, + input: &parse_rust::TRANSFER_AUTHORITY_RS, + pda_accounts: &[("state_pda", STATE_PDA)], + }, +]; + +/// The struct types the IDL defines, as `(source, Rust name, IDL name)`. The +/// two names don't always agree: `StateAccount` is called `SettlementState` in +/// the IDL, matching the `SettlementAccount` variant that names the account. +const STRUCT_TYPES: &[(&Source, &str, &str)] = &[ + (&parse_rust::ORDER_RS, "OrderAccount", "OrderAccount"), + (&parse_rust::STATE_RS, "StateAccount", "SettlementState"), + (&parse_rust::INTENT_RS, "OrderIntent", "OrderIntent"), +]; + +/// The enum types the IDL defines, as `(source, name)`. +pub const ENUM_TYPES: &[(&Source, &str)] = &[ + (&parse_rust::INTENT_RS, "OrderKind"), + (&parse_rust::INTERFACE_LIB_RS, "Role"), +]; + +/// The enum whose variants are the IDL's `errors[]`. +const ERRORS: &str = "SettlementError"; + +/// `BeginSettle` reads as `begin_settle`: the IDL names instructions the way +/// Rust names functions, where the discriminator enum names them as variants. +fn pascal_to_snake(s: &str) -> String { + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() { + if i != 0 { + out.push('_'); + } + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// Generate an incomplete IDL document based on the Rust source. +pub fn partial_idl() -> Value { + json!({ + "address": cow_settlement_interface::ID.to_string(), + "metadata": { + // Bumping the minor version also moves every PDA, so a stale value + // here hides that. + "version": env!("CARGO_PKG_VERSION"), + }, + "instructions": instructions(), + "accounts": accounts(), + "types": types(), + "errors": errors(), + }) +} + +/// One entry per `SettlementInstruction` variant, in discriminator order. +fn instructions() -> Vec { + discriminator_variants(&parse_rust::INTERFACE_LIB_RS.find_enum("SettlementInstruction")) + .map(|(byte, variant)| { + let instruction = INSTRUCTIONS + .iter() + .find(|instruction| instruction.variant.discriminator() == byte) + .unwrap_or_else(|| { + panic!( + "SettlementInstruction::{} is missing from INSTRUCTIONS", + variant.ident + ) + }); + + let mut entry = Map::new(); + entry.insert( + "name".into(), + json!(pascal_to_snake(&variant.ident.to_string())), + ); + insert_docs(&mut entry, parse_rust::docs(&variant.attrs)); + entry.insert("discriminator".into(), json!([byte])); + if !instruction.pda_accounts.is_empty() { + entry.insert("accounts".into(), pda_accounts(instruction)); + } + entry.insert("args".into(), args(instruction, &variant.ident.to_string())); + Value::Object(entry) + }) + .collect() +} + +/// The `accounts[]` entries an instruction derives as PDAs, each carrying only +/// its name and its seeds. +fn pda_accounts(instruction: &Instruction) -> Value { + let accounts: Vec = instruction + .pda_accounts + .iter() + .map(|(name, seeds)| { + let seeds: Vec = seeds.iter().map(Seed::to_idl).collect(); + json!({ "name": name, "pda": { "seeds": seeds } }) + }) + .collect(); + Value::Array(accounts) +} + +/// An instruction's `args[]`, read off its `Input` struct. +/// +/// That struct is the closest thing the source has to `args[]`: its fields are +/// what a handler gets after parsing, holding the borrowed accounts (`&'a A`) +/// and the trailing repeated groups next to the values the instruction data +/// carries, in the order the data carries them. Dropping every field whose type +/// the IDL's grammar can't name leaves exactly the arguments — with one +/// exception, [`arg_alias`]. +fn args(instruction: &Instruction, variant: &str) -> Value { + let input_name = format!("{variant}Input"); + let input = instruction.input.find_struct(&input_name); + + let args: Vec = input + .fields + .iter() + .filter_map(|field| { + let name = parse_rust::field_name(field, &input_name); + let (name, ty) = match arg_override(instruction.variant, &name) { + Some(aliased) => aliased, + None => (name, parse_rust::try_type_to_idl(&field.ty)?), + }; + Some(json!({ "name": name, "type": ty })) + }) + .collect(); + Value::Array(args) +} + +/// In cases where the IDL needs to differ from the rust code, an override can be set here. +fn arg_override(variant: SettlementInstruction, field: &str) -> Option<(String, Value)> { + match (variant, field) { + (SettlementInstruction::CreateOrder, "intent_bytes") => Some(( + "intent".to_string(), + json!({ "defined": { "name": "OrderIntent" } }), + )), + _ => None, + } +} + +/// One entry per `SettlementAccount` variant, in discriminator order. +fn accounts() -> Vec { + discriminator_variants(&parse_rust::INTERFACE_LIB_RS.find_enum("SettlementAccount")) + .map(|(byte, variant)| { + let mut entry = Map::new(); + entry.insert("name".into(), json!(variant.ident.to_string())); + insert_docs(&mut entry, parse_rust::docs(&variant.attrs)); + entry.insert("discriminator".into(), json!([byte])); + Value::Object(entry) + }) + .collect() +} + +/// One `types[]` entry per struct and enum in the tables above. +fn types() -> Vec { + let structs = STRUCT_TYPES.iter().map(|(source, rust_name, idl_name)| { + let rust_struct = source.find_struct(rust_name); + type_entry( + idl_name, + parse_rust::docs(&rust_struct.attrs), + parse_rust::struct_type(&rust_struct, rust_name), + ) + }); + let enums = ENUM_TYPES.iter().map(|(source, name)| { + let rust_enum = source.find_enum(name); + type_entry( + name, + parse_rust::docs(&rust_enum.attrs), + parse_rust::enum_type(&rust_enum), + ) + }); + structs.chain(enums).collect() +} + +fn type_entry(idl_name: &str, docs: Vec, ty: Value) -> Value { + let mut entry = Map::new(); + entry.insert("name".into(), json!(idl_name)); + insert_docs(&mut entry, docs); + entry.insert("type".into(), ty); + Value::Object(entry) +} + +/// One `errors[]` entry per [`ERRORS`] variant. A variant's discriminant is the +/// `ProgramError::Custom` code the program returns, and its doc comment is the +/// message the IDL publishes for that code. +fn errors() -> Vec { + parse_rust::INTERFACE_LIB_RS + .find_enum(ERRORS) + .variants + .iter() + .map(|variant| { + json!({ + "code": parse_rust::discriminant(variant), + "name": variant.ident.to_string(), + "msg": parse_rust::normalize_doc(&parse_rust::docs(&variant.attrs)), + }) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// The variants of a discriminator enum paired with their wire byte, in +/// discriminator order. +fn discriminator_variants( + rust_enum: &syn::ItemEnum, +) -> impl Iterator + '_ { + let mut variants: Vec<(u8, &syn::Variant)> = rust_enum + .variants + .iter() + .map(|variant| { + let byte = parse_rust::discriminant(variant); + let byte = + u8::try_from(byte).unwrap_or_else(|_| panic!("{} must fit in a u8", variant.ident)); + (byte, variant) + }) + .collect(); + variants.sort_by_key(|(byte, _)| *byte); + variants.into_iter() +} + +/// Records `docs` on an entry, leaving the key out entirely when the Rust +/// source documents nothing. An empty `docs` would claim the IDL must say +/// nothing either, which is the opposite of what a missing doc comment means. +fn insert_docs(entry: &mut Map, docs: Vec) { + if !docs.is_empty() { + entry.insert("docs".into(), json!(docs)); + } +} diff --git a/programs/settlement/tests/idl/main.rs b/programs/settlement/tests/idl/main.rs index cea64d25..31ff0630 100644 --- a/programs/settlement/tests/idl/main.rs +++ b/programs/settlement/tests/idl/main.rs @@ -1,75 +1,44 @@ //! IDL correctness tests for `programs/settlement/idl/cow_settlement.json`. //! //! This program is a native Pinocchio program with a hand-written IDL (no -//! `anchor idl build`/shank step keeps it in sync), so these tests -//! cross-check the checked-in file against the Rust source it describes. +//! `anchor idl build`/shank step keeps it in sync), so these tests cross-check +//! the checked-in file against the Rust source it describes. //! -//! [`parse_json`] reads the IDL and [`parse_rust`] reads the Rust source; the -//! tests here only compare what the two report. +//! The cross-check runs in one direction. [`parse_rust`] reads the program's +//! source, [`generate`] assembles what it finds into a partial IDL — a document +//! shaped like the real one, carrying only the facts the source pins — and +//! [`superset`] asserts the checked-in file states all of them. Every run also +//! writes that generated document next to the build output, so a failure can be +//! read as a diff rather than as a list of assertions. +//! +//! The remaining tests here are the ones with no Rust-side counterpart at all: +//! the file has to be valid, canonically formatted JSON, and it has to satisfy +//! the IDL spec's own schema. -mod parse_json; +mod generate; mod parse_rust; +mod superset; -use std::collections::BTreeSet; - -use cow_settlement_interface::{ - pda::{buffer::BUFFER_SEED, order::ORDER_SEED, SETTLEMENT_SEED}, - SettlementAccount, SettlementInstruction, -}; -use parse_json::{Section, IDL}; -use serde_json::{json, Value}; - -fn pascal_to_snake(s: &str) -> String { - let mut out = String::new(); - for (i, c) in s.chars().enumerate() { - if c.is_uppercase() { - if i != 0 { - out.push('_'); - } - out.extend(c.to_lowercase()); - } else { - out.push(c); - } - } - out -} +use std::{fs, path::PathBuf, sync::LazyLock}; -fn confirm_idl_match(idl_section: Section, idl_name: &str, discriminator_byte: u8) { - let idl_element = parse_json::find_item(idl_section, idl_name) - .unwrap_or_else(|| panic!("IDL does not contain defined settlement element {idl_name}")); +use serde_json::Value; - // confirm the discriminator matches - assert_eq!( - parse_json::discriminator(idl_element, idl_name), - vec![discriminator_byte], - "IDL discriminator for {idl_name} should be the single byte the code uses" - ); - - // confirm the docs match: everything the Rust enum variant documents must - // show up, in order, in the IDL element's docs. The IDL is allowed to say - // more than the Rust source does; it carries notes about what its own - // grammar can't express (`begin_settle`'s dynamically-shaped tail, for - // one), which have no business being in the program's own docs. - let idl_docs = parse_json::docs(idl_element, idl_name); - let rust_docs = parse_rust::discriminator_variant_docs(idl_section, discriminator_byte); - - let mut unmatched_idl_docs = idl_docs.iter(); - for rust_doc in &rust_docs { - assert!( - unmatched_idl_docs.any(|idl_doc| idl_doc == rust_doc), - "IDL docs for {idl_name} don't document what the Rust source does; missing (or \ - out of order) paragraph:\n{rust_doc}\nIDL docs are:\n{idl_docs:#?}" - ); - } -} +const IDL_JSON: &str = include_str!("../../idl/cow_settlement.json"); +const SCHEMA_JSON: &str = include_str!("../../idl/schema/idl-spec-v0.1.0.json"); -// --------------------------------------------------------------------------- -// JSON validity and formatting -// --------------------------------------------------------------------------- +static IDL: LazyLock = + LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); -#[test] -fn idl_is_valid_json() { - let _ = &*IDL; +/// Where [`idl_states_everything_the_rust_source_does`] leaves the document it +/// generated, for reading by hand when the assertion it drives fails. +/// +/// `CARGO_TARGET_TMPDIR` is `target/tmp`, so its parent is the target directory +/// wherever cargo put it. +fn generated_idl_path() -> PathBuf { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")) + .parent() + .expect("CARGO_TARGET_TMPDIR lives inside the target directory") + .join("generated_cow_settlement_idl.json") } #[test] @@ -77,38 +46,15 @@ fn idl_is_pretty_formatted() { let mut formatted = serde_json::to_string_pretty(&*IDL).expect("IDL JSON should re-serialize"); formatted.push('\n'); assert_eq!( - formatted, - parse_json::IDL_JSON, + formatted, IDL_JSON, "IDL isn't canonically formatted; regenerate it with `serde_json::to_string_pretty` \ plus a trailing newline" ); } -#[test] -fn idl_address_matches_declared_program_id() { - assert_eq!( - IDL["address"].as_str().expect("address must be a string"), - cow_settlement_interface::ID.to_string(), - "IDL `address` must match the program id declared via declare_id! in interface/src/lib.rs" - ); -} - -#[test] -fn idl_version_matches_cargo_package_version() { - assert_eq!( - IDL["metadata"]["version"] - .as_str() - .expect("metadata.version must be a string"), - env!("CARGO_PKG_VERSION"), - "IDL `metadata.version` must match the settlement program's cargo package version; \ - bumping the minor version also moves every PDA, so a stale value here hides that" - ); -} - #[test] fn idl_conforms_to_official_schema() { - let schema: Value = - serde_json::from_str(parse_json::SCHEMA_JSON).expect("schema should be valid JSON"); + let schema: Value = serde_json::from_str(SCHEMA_JSON).expect("schema should be valid JSON"); // The bundled schema has to be the one `metadata.spec` claims to follow; // validating against some other spec's schema would prove nothing about @@ -131,298 +77,40 @@ fn idl_conforms_to_official_schema() { ); } -/// Asserts `section[]` holds nothing beyond the `matched` entries a test just -/// cross-checked against the Rust source. Every check reads the Rust source and -/// looks for what it found in the IDL, never the other way around, so without -/// this an entry the IDL invented, or one a rename left behind, is never looked -/// at by anything. -fn confirm_no_extra_idl_entries(idl_section: Section, matched: usize) { - assert_eq!( - parse_json::section(idl_section).len(), - matched, - "IDL {idl_section}[] carries entries with no counterpart in the Rust source" - ); -} - -#[test] -fn idl_matches_instruction_discriminators() { - let mut matched = 0; - for byte in 0u8..=255 { - if let Ok(ix) = SettlementInstruction::try_from(byte) { - confirm_idl_match( - Section::Instructions, - &pascal_to_snake(&format!("{ix:?}")), - byte, - ); - matched += 1; - } - } - confirm_no_extra_idl_entries(Section::Instructions, matched); -} - +/// The one test that compares the two sides directly. Everything the Rust source says +/// about the program's interface has to be in the checked-in IDL, said the same +/// way; what the source can't say, the IDL is free to fill in. #[test] -fn idl_matches_account_discriminators() { - let mut matched = 0; - for byte in 0u8..=255 { - if let Ok(account) = SettlementAccount::try_from(byte) { - confirm_idl_match(Section::Accounts, &format!("{account:?}"), byte); - matched += 1; - } - } - confirm_no_extra_idl_entries(Section::Accounts, matched); -} +fn idl_matches_everything_generated_from_rust() { + let generated = generate::partial_idl(); -/// Cross-checks one IDL `types[]` entry against the Rust struct it describes. -/// -/// `idl_name` is passed separately because the two don't always agree: -/// `StateAccount` is called `SettlementState` in the IDL, matching the -/// `SettlementAccount` discriminator variant that names the account. -fn confirm_idl_types_entry( - rust_source: &parse_rust::Source, - rust_type_name: &str, - idl_type_name: &str, -) { - // load in the idl and rust type definitions - let type_in_idl = parse_json::find_item(Section::Types, idl_type_name) - .unwrap_or_else(|| panic!("IDL types[] must contain {idl_type_name}")); - let rust_struct = rust_source.find_struct(rust_type_name); - - // confirm the docs match. `types[]` carries one docs entry per source - // line where `instructions[]` carries one per paragraph, so both sides are - // compared as a single joined block: same prose, wrapping not load-bearing. - assert_eq!( - parse_json::docs(type_in_idl, idl_type_name).join(" "), - parse_rust::docs(&rust_struct.attrs).join(" "), - "documentation between rust and IDL types should be the same for {rust_type_name} and {idl_type_name}" - ); + let path = generated_idl_path(); + let mut json = serde_json::to_string_pretty(&generated).expect("generated IDL must serialize"); + json.push('\n'); + fs::write(&path, &json) + .unwrap_or_else(|err| panic!("{} must be writable: {err}", path.display())); + println!("generated IDL written to {}", path.display()); - // confirm fields match, both in name/order and in type - assert_eq!( - parse_json::struct_fields(type_in_idl, idl_type_name), - parse_rust::struct_fields(&rust_struct, idl_type_name), - "{idl_type_name}'s IDL fields must match the Rust struct {idl_type_name} in name, order and type" - ); -} - -/// Cross-checks one IDL `types[]` entry against the Rust enum it describes. -/// -/// The spec's enum variants carry a name and nothing else: no discriminant, -/// since a variant's index is its wire byte, and nowhere to put docs. So the -/// variant names in order, plus the type's own docs, are the whole comparison. -fn confirm_idl_types_enum(rust_source: &parse_rust::Source, name: &str) { - let type_in_idl = parse_json::find_item(Section::Types, name) - .unwrap_or_else(|| panic!("IDL types[] must contain {name}")); - let rust_enum = rust_source.find_enum(name); - - // docs are joined and compared as one block, as they are for structs - assert_eq!( - parse_json::docs(type_in_idl, name).join(" "), - parse_rust::docs(&rust_enum.attrs).join(" "), - "documentation between rust and IDL types should be the same for {name}" - ); - - assert_eq!( - parse_json::enum_variants(type_in_idl, name), - parse_rust::enum_variants(&rust_enum), - "{name}'s IDL variants must match the Rust enum {name} in name and order" - ); - - // Position is all the IDL can say about a variant's wire value, so a Rust - // variant that pins its own discriminant has to agree with its index. - for (index, variant) in rust_enum.variants.iter().enumerate() { - let index = u64::try_from(index).expect("variant index should fit in a u64"); - if let Some(declared) = parse_rust::declared_discriminant(variant) { - assert_eq!( - declared, index, - "{name}::{} declares discriminant {declared} but sits at index {index}; the IDL \ - can only express a variant's wire value as its position", - variant.ident - ); - } - } -} - -#[test] -fn idl_matches_rust_types() { - // `(source, Rust name, IDL name)` of the struct types, and - // `(source, name)` of the enum types. Everything the IDL defines has to be - // listed here; the count is what catches a `types[]` entry the program has - // no definition for. - let structs = [ - (&parse_rust::INTENT_RS, "OrderIntent", "OrderIntent"), - (&parse_rust::ORDER_RS, "OrderAccount", "OrderAccount"), - (&parse_rust::STATE_RS, "StateAccount", "SettlementState"), - ]; - let enums = [ - (&parse_rust::INTENT_RS, "OrderKind"), - (&parse_rust::INTERFACE_LIB_RS, "Role"), - ]; - - for (rust_source, rust_type_name, idl_type_name) in structs { - confirm_idl_types_entry(rust_source, rust_type_name, idl_type_name); - } - for (rust_source, name) in enums { - confirm_idl_types_enum(rust_source, name); - } - - confirm_no_extra_idl_entries(Section::Types, structs.len() + enums.len()); + superset::assert_superset(&generated, &IDL); } +/// The IDL spec can only express a variant's wire value as its position, so a +/// Rust variant that pins its own discriminant has to agree with its index. +/// Nothing in the generated document can carry this: it's a fact about the Rust +/// enum, checked so the `variants[]` the IDL does carry mean what they say. #[test] -fn idl_matches_rust_errors() { - let rust_errors_type = parse_rust::INTERFACE_LIB_RS.find_enum("SettlementError"); - - for rust_err in &rust_errors_type.variants { - let idl_err = parse_json::find_item(Section::Errors, &rust_err.ident.to_string()) - .unwrap_or_else(|| { - panic!( - "Settlement program error {} is not defined in IDL errors[]", - rust_err.ident - ) - }); - - // the idl error's "code" should match up - let idl_code = idl_err["code"] - .as_u64() - .expect("code should be correct type"); - - assert_eq!( - idl_code, - parse_rust::discriminant(rust_err), - "IDL errors[] name={} should match the code in rust", - rust_err.ident - ); - - // the idl error's "msg" should match up - let rust_msg = parse_rust::docs(&rust_err.attrs).join(" "); - let idl_msg = idl_err["msg"].as_str().expect("msg must be a string"); - - assert_eq!( - idl_msg, rust_msg, - "IDL errors[] name={} should match informational msg", - rust_err.ident - ); - } - - confirm_no_extra_idl_entries(Section::Errors, rust_errors_type.variants.len()); -} - -/// Cross-checks one IDL instruction's `args[]` against the builder struct whose -/// fields become the instruction data. -/// -/// A builder carries the program id and the instruction's accounts too, so the -/// args are matched by name against a subset of its fields: each arg has to name -/// a real field, the two have to agree on type, and the args have to be ordered -/// the way the builder declares the fields, which is the order its -/// `From for Instruction` writes them to the wire. -fn confirm_idl_args_match( - rust_source: &parse_rust::Source, - builder_name: &str, - idl_instruction: &str, -) { - let idl_element = parse_json::find_item(Section::Instructions, idl_instruction) - .unwrap_or_else(|| panic!("IDL instructions[] must contain {idl_instruction}")); - let builder = rust_source.find_struct(builder_name); - let field_names = parse_rust::field_names(&builder, builder_name); - - let mut previous_arg: Option<(String, usize)> = None; - for (arg_name, idl_type) in parse_json::args(idl_element, idl_instruction) { - // panics naming the field if the builder has no such field at all - assert_eq!( - idl_type, - parse_rust::field_type(&builder, &arg_name, builder_name), - "{idl_instruction}'s arg {arg_name} must have the type {builder_name}.{arg_name} \ - declares" - ); - - let position = field_names - .iter() - .position(|field| *field == arg_name) - .expect("field_type panics before this on a field the builder doesn't declare"); - if let Some((previous_name, previous_position)) = &previous_arg { - assert!( - *previous_position < position, - "{idl_instruction} lists arg {arg_name} after {previous_name}, but {builder_name} \ - declares them the other way around; args[] is in wire order" - ); +fn enum_type_discriminants_match_variant_order() { + for (source, name) in generate::ENUM_TYPES { + for (index, variant) in source.find_enum(name).variants.iter().enumerate() { + let index = u64::try_from(index).expect("variant index should fit in a u64"); + if let Some(declared) = parse_rust::declared_discriminant(variant) { + assert_eq!( + declared, index, + "{name}::{} declares discriminant {declared} but sits at index {index}; the \ + IDL can only express a variant's wire value as its position", + variant.ident + ); + } } - previous_arg = Some((arg_name, position)); - } -} - -#[test] -fn idl_matches_instruction_args() { - confirm_idl_args_match(&parse_rust::INITIALIZE_RS, "Initialize", "initialize"); - confirm_idl_args_match(&parse_rust::BEGIN_SETTLE_RS, "BeginSettle", "begin_settle"); - confirm_idl_args_match( - &parse_rust::FINALIZE_SETTLE_RS, - "FinalizeSettle", - "finalize_settle", - ); - confirm_idl_args_match( - &parse_rust::TRANSFER_AUTHORITY_RS, - "TransferAuthority", - "transfer_authority", - ); - - // `create_order` is the one instruction whose builder doesn't mirror its - // arg: `CreateOrder` takes the intent already encoded, as - // `intent_bytes: [u8; EncodedOrderIntent::SIZE]`, where the IDL names the - // decoded type. That the two describe the same bytes is what - // `idl_matches_rust_types` checks of `OrderIntent` itself. - let create_order = parse_json::find_item(Section::Instructions, "create_order") - .expect("IDL instructions[] must contain create_order"); - assert_eq!( - parse_json::args(create_order, "create_order"), - vec![( - "intent".to_string(), - json!({ "defined": { "name": "OrderIntent" } }) - )], - "create_order takes exactly one arg, the encoded OrderIntent" - ); - let builder = parse_rust::CREATE_ORDER_RS.find_struct("CreateOrder"); - assert!( - parse_rust::field_names(&builder, "CreateOrder").contains(&"intent_bytes".to_string()), - "CreateOrder must still carry the encoded intent that create_order's `intent` arg describes" - ); - - // Instructions whose data is nothing but the discriminator byte. - for idl_instruction in ["create_buffer", "reclaim_order", "reclaim_buffer"] { - let idl_element = parse_json::find_item(Section::Instructions, idl_instruction) - .unwrap_or_else(|| panic!("IDL instructions[] must contain {idl_instruction}")); - assert!( - parse_json::args(idl_element, idl_instruction).is_empty(), - "{idl_instruction} carries no instruction data beyond its discriminator" - ); - } -} - -#[test] -fn idl_pda_seed_literals_match_pda_module() { - let known: BTreeSet<&[u8]> = [SETTLEMENT_SEED, BUFFER_SEED, ORDER_SEED] - .into_iter() - .collect(); - - let found = parse_json::const_seeds(parse_json::section(Section::Instructions)); - - // Every const seed the IDL does declare must be a real seed constant. Not - // every seed constant needs to show up: `order_pda`'s canonical seed - // includes `sha256(intent)`, which the IDL can't express as a static - // `pda` entry at all (documented in create_order's docs), so `ORDER_SEED` - // legitimately never appears here. - for seed in &found { - assert!( - known.contains(seed.as_slice()), - "IDL PDA const seed {:?} doesn't match any seed constant in interface::pda", - String::from_utf8_lossy(seed), - ); } - assert!( - found.iter().any(|s| s.as_slice() == SETTLEMENT_SEED), - "expected SETTLEMENT_SEED to appear in some IDL PDA `seeds`", - ); - assert!( - found.iter().any(|s| s.as_slice() == BUFFER_SEED), - "expected BUFFER_SEED to appear in some IDL PDA `seeds`", - ); } diff --git a/programs/settlement/tests/idl/parse_json.rs b/programs/settlement/tests/idl/parse_json.rs deleted file mode 100644 index f0532cad..00000000 --- a/programs/settlement/tests/idl/parse_json.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Readers for the checked-in IDL, `programs/settlement/idl/cow_settlement.json`. -//! -//! Accessors panic naming the entry that went missing or came back the wrong -//! shape, so a malformed IDL fails where it's read rather than where it's -//! compared. - -use std::{fmt, sync::LazyLock}; - -use serde_json::Value; - -use crate::parse_rust::normalize_doc; - -pub const IDL_JSON: &str = include_str!("../../idl/cow_settlement.json"); -pub const SCHEMA_JSON: &str = include_str!("../../idl/schema/idl-spec-v0.1.0.json"); - -pub static IDL: LazyLock = - LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); - -/// A top-level array of the IDL. Naming the sections the tests read as a type -/// keeps a mistyped section from reading as a missing entry. -#[derive(Clone, Copy)] -pub enum Section { - Instructions, - Accounts, - Types, - Errors, -} - -impl Section { - /// The IDL key this section lives under. - pub fn as_str(self) -> &'static str { - match self { - Self::Instructions => "instructions", - Self::Accounts => "accounts", - Self::Types => "types", - Self::Errors => "errors", - } - } -} - -impl fmt::Display for Section { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -/// The top-level `section[]` array. -pub fn section(section: Section) -> &'static [Value] { - IDL[section.as_str()] - .as_array() - .unwrap_or_else(|| panic!("IDL {section}[] must be an array")) -} - -/// Under the array for `section_name`, find the object where the `name` matches the given value. -/// Since each IDL section follows the same pattern where each section is an array of objects which contain a field `name`, this function -/// is useful for finding just about any item we need in the IDL file. -pub fn find_item(section_name: Section, name: &str) -> Option<&'static Value> { - section(section_name) - .iter() - .find(|item| item["name"] == name) -} - -/// An entry's `docs`, one [`normalize_doc`]d string per entry, so they compare -/// directly against what [`crate::parse_rust::docs`] reports. Absent docs read -/// as none at all; the caller compares against the Rust source to decide -/// whether that's wrong. -pub fn docs(item: &Value, context: &str) -> Vec { - let Some(docs) = item.get("docs") else { - return Vec::new(); - }; - docs.as_array() - .unwrap_or_else(|| panic!("docs for {context} should be an array")) - .iter() - .map(|doc| { - let doc = doc - .as_str() - .unwrap_or_else(|| panic!("doc entry for {context} should be a string")); - normalize_doc(&[doc]) - }) - .collect() -} - -/// An entry's `discriminator` bytes. -pub fn discriminator(item: &Value, context: &str) -> Vec { - item["discriminator"] - .as_array() - .unwrap_or_else(|| panic!("discriminator for {context} should be an array")) - .iter() - .map(|byte| { - let byte = byte - .as_u64() - .unwrap_or_else(|| panic!("discriminator byte for {context} should be a number")); - u8::try_from(byte) - .unwrap_or_else(|_| panic!("discriminator byte for {context} should fit in a u8")) - }) - .collect() -} - -/// The `(name, type)` pairs of an array of `{"name", "type"}` objects, in -/// declaration order. `types[]` fields and `instructions[]` args share this -/// shape. -fn name_type_pairs(entries: &[Value], context: &str) -> Vec<(String, Value)> { - entries - .iter() - .map(|entry| { - ( - entry["name"] - .as_str() - .unwrap_or_else(|| panic!("entry name in {context} must be a string")) - .to_string(), - entry["type"].clone(), - ) - }) - .collect() -} - -/// The `(name, type)` pairs of a struct `types[]` entry, in declaration order. -pub fn struct_fields(item: &Value, context: &str) -> Vec<(String, Value)> { - name_type_pairs( - item["type"]["fields"] - .as_array() - .unwrap_or_else(|| panic!("struct type {context} should have a fields array")), - context, - ) -} - -/// The `(name, type)` pairs of an `instructions[]` entry's `args`, in wire -/// order. An absent `args` reads as no arguments at all, which is how the IDL -/// spells an instruction whose data is just the discriminator. -pub fn args(item: &Value, context: &str) -> Vec<(String, Value)> { - let Some(args) = item.get("args") else { - return Vec::new(); - }; - name_type_pairs( - args.as_array() - .unwrap_or_else(|| panic!("args for {context} should be an array")), - context, - ) -} - -/// The variant names of an enum `types[]` entry, in declaration order, which is -/// the order the wire discriminant counts in. The spec's `IdlEnumVariant` has -/// nowhere to put per-variant docs, so there's nothing else here to compare. -pub fn enum_variants(item: &Value, context: &str) -> Vec { - item["type"]["variants"] - .as_array() - .unwrap_or_else(|| panic!("enum type {context} should have a variants array")) - .iter() - .map(|variant| { - variant["name"] - .as_str() - .unwrap_or_else(|| panic!("variant name in {context} must be a string")) - .to_string() - }) - .collect() -} - -/// Every `{"kind": "const"}` seed byte string anywhere below `values`. -pub fn const_seeds(values: &[Value]) -> Vec> { - fn collect(value: &Value, out: &mut Vec>) { - match value { - Value::Object(map) => { - if map.get("kind").and_then(Value::as_str) == Some("const") { - if let Some(Value::Array(bytes)) = map.get("value") { - let decoded: Vec = bytes - .iter() - .map(|b| b.as_u64().expect("seed byte must be a number") as u8) - .collect(); - out.push(decoded); - } - } - for v in map.values() { - collect(v, out); - } - } - Value::Array(arr) => arr.iter().for_each(|v| collect(v, out)), - _ => {} - } - } - - let mut found = Vec::new(); - for value in values { - collect(value, &mut found); - } - found -} diff --git a/programs/settlement/tests/idl/parse_rust.rs b/programs/settlement/tests/idl/parse_rust.rs index d1874272..c932e801 100644 --- a/programs/settlement/tests/idl/parse_rust.rs +++ b/programs/settlement/tests/idl/parse_rust.rs @@ -1,12 +1,12 @@ //! Readers for the Rust sources the IDL describes. //! -//! Everything here goes through `syn`, panicking with the source path when the -//! item the IDL claims to describe isn't there to compare against. +//! Everything here goes through `syn` and reports what it finds in the IDL +//! spec's own JSON grammar, so [`crate::generate`] can assemble it straight +//! into an IDL document. Lookups panic with the source path when the item the +//! IDL claims to describe isn't there to read. use serde_json::{json, Value}; -use crate::parse_json::Section; - /// A Rust source file, compiled in so the tests parse the same text the /// program does. pub struct Source { @@ -40,6 +40,11 @@ pub const INITIALIZE_RS: Source = Source { text: include_str!("../../../../interface/src/instruction/initialize.rs"), }; +pub const CREATE_BUFFER_RS: Source = Source { + display: "interface/src/instruction/create_buffer.rs", + text: include_str!("../../../../interface/src/instruction/create_buffer.rs"), +}; + pub const CREATE_ORDER_RS: Source = Source { display: "interface/src/instruction/create_order.rs", text: include_str!("../../../../interface/src/instruction/create_order.rs"), @@ -55,6 +60,16 @@ pub const FINALIZE_SETTLE_RS: Source = Source { text: include_str!("../../../../interface/src/instruction/settle/finalize.rs"), }; +pub const RECLAIM_ORDER_RS: Source = Source { + display: "interface/src/instruction/reclaim_order.rs", + text: include_str!("../../../../interface/src/instruction/reclaim_order.rs"), +}; + +pub const RECLAIM_BUFFER_RS: Source = Source { + display: "interface/src/instruction/reclaim_buffer.rs", + text: include_str!("../../../../interface/src/instruction/reclaim_buffer.rs"), +}; + pub const TRANSFER_AUTHORITY_RS: Source = Source { display: "interface/src/instruction/transfer_authority.rs", text: include_str!("../../../../interface/src/instruction/transfer_authority.rs"), @@ -91,39 +106,22 @@ impl Source { } } -/// The variant of `rust_enum` whose discriminant is `byte`. -pub fn variant_by_discriminant(rust_enum: &syn::ItemEnum, byte: u8) -> &syn::Variant { - rust_enum - .variants - .iter() - .find(|variant| discriminant(variant) == u64::from(byte)) - .unwrap_or_else(|| { - panic!( - "{} must have a variant with discriminant {byte}", - rust_enum.ident - ) - }) -} - -/// Translates a Rust field type into the IDL spec's type grammar, so field -/// types can be compared as JSON. Panics on anything the program's data types -/// don't currently use. -pub fn type_to_idl(ty: &syn::Type, context: &str) -> Value { +/// Translates a Rust type into the IDL spec's type grammar, or `None` for the +/// types that grammar can't name: borrowed accounts (`&'a A`), the repeated +/// groups the instructions carry as trailing accounts, and arrays whose length +/// is a named constant rather than a literal. +pub fn try_type_to_idl(ty: &syn::Type) -> Option { match ty { syn::Type::Path(path) => { - let ident = path - .path - .get_ident() - .unwrap_or_else(|| panic!("{context}: expected a plain type name")) - .to_string(); - match ident.as_str() { + let ident = path.path.get_ident()?.to_string(); + Some(match ident.as_str() { "Pubkey" => json!("pubkey"), "bool" | "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => json!(ident), // Anything else is one of this crate's own types, which the IDL // carries as its own `types[]` entry and references by name. _ => json!({ "defined": { "name": ident } }), - } + }) } syn::Type::Array(array) => { let syn::Expr::Lit(syn::ExprLit { @@ -131,15 +129,24 @@ pub fn type_to_idl(ty: &syn::Type, context: &str) -> Value { .. }) = &array.len else { - panic!("{context}: array length must be an integer literal"); + return None; }; - let len: u64 = len.base10_parse().expect("array length must be a u64"); - json!({ "array": [type_to_idl(&array.elem, context), len] }) + let len: u64 = len.base10_parse().ok()?; + Some(json!({ "array": [try_type_to_idl(&array.elem)?, len] })) } - _ => panic!("{context}: unsupported field type"), + _ => None, } } +/// Translates a Rust type into the IDL spec's type grammar, panicking on +/// anything the grammar can't name. Used where the IDL is expected to describe +/// the type in full, so a translation that can't be made is a broken IDL rather +/// than a limit to work around. +pub fn type_to_idl(ty: &syn::Type, context: &str) -> Value { + try_type_to_idl(ty) + .unwrap_or_else(|| panic!("{context}: the IDL's type grammar can't name this type")) +} + /// The `= N` discriminant an enum variant declares, or `None` where it leans on /// the implicit "one past the previous variant" value. pub fn declared_discriminant(variant: &syn::Variant) -> Option { @@ -168,58 +175,43 @@ pub fn discriminant(variant: &syn::Variant) -> u64 { .unwrap_or_else(|| panic!("discriminant should be defined on {}", variant.ident)) } -/// The `(name, type)` pairs of a struct's fields, in declaration order, with -/// each type already translated into the IDL's type grammar. -pub fn struct_fields(rust_struct: &syn::ItemStruct, context: &str) -> Vec<(String, Value)> { - rust_struct +/// A struct as an IDL `types[]` entry's `type`: `{"kind": "struct", "fields": +/// [...]}`, with the fields in declaration order, which is the order they're +/// laid out on the wire. +pub fn struct_type(rust_struct: &syn::ItemStruct, context: &str) -> Value { + let fields: Vec = rust_struct .fields .iter() .map(|field| { - let name = field - .ident - .as_ref() - .unwrap_or_else(|| panic!("{context} should have named fields")) - .to_string(); - let idl_type = type_to_idl(&field.ty, &format!("{context}.{name}")); - (name, idl_type) + let name = field_name(field, context); + let ty = type_to_idl(&field.ty, &format!("{context}.{name}")); + json!({ "name": name, "type": ty }) }) - .collect() + .collect(); + json!({ "kind": "struct", "fields": fields }) } -/// The names of a struct's fields, in declaration order. Unlike -/// [`struct_fields`] this translates no types, so it reads structs carrying -/// fields the IDL's type grammar has no equivalent for. -pub fn field_names(rust_struct: &syn::ItemStruct, context: &str) -> Vec { - rust_struct - .fields - .iter() - .map(|field| { - field - .ident - .as_ref() - .unwrap_or_else(|| panic!("{context} should have named fields")) - .to_string() - }) - .collect() -} - -/// One named field's type, translated into the IDL's type grammar. -pub fn field_type(rust_struct: &syn::ItemStruct, name: &str, context: &str) -> Value { - let field = rust_struct - .fields +/// An enum as an IDL `types[]` entry's `type`: `{"kind": "enum", "variants": +/// [...]}`, with the variants in declaration order, which is the order the wire +/// discriminant counts in. The spec's `IdlEnumVariant` carries a name and +/// nothing else — no discriminant, since a variant's index is its wire byte, +/// and nowhere to put docs. +pub fn enum_type(rust_enum: &syn::ItemEnum) -> Value { + let variants: Vec = rust_enum + .variants .iter() - .find(|field| field.ident.as_ref().is_some_and(|ident| ident == name)) - .unwrap_or_else(|| panic!("{context} should have a field named {name}")); - type_to_idl(&field.ty, &format!("{context}.{name}")) + .map(|variant| json!({ "name": variant.ident.to_string() })) + .collect(); + json!({ "kind": "enum", "variants": variants }) } -/// The variant names of an enum, in declaration order. -pub fn enum_variants(rust_enum: &syn::ItemEnum) -> Vec { - rust_enum - .variants - .iter() - .map(|variant| variant.ident.to_string()) - .collect() +/// One field's name. +pub fn field_name(field: &syn::Field, context: &str) -> String { + field + .ident + .as_ref() + .unwrap_or_else(|| panic!("{context} should have named fields")) + .to_string() } /// Unwraps rustdoc intra-doc links to the text they display: `[`Role`](Role)` @@ -255,10 +247,8 @@ fn strip_doc_links(text: &str) -> String { out } -/// Collapses doc lines into the single line the IDL carries them as, dropping -/// the backtick and link markup the IDL only carries in some places. Both this -/// module and [`crate::parse_json`] hand their doc text through here, so the two -/// sides are always compared in the same form. +/// Collapses doc lines into a single line, dropping the backtick and link +/// markup the IDL only carries in some places. pub fn normalize_doc>(lines: &[S]) -> String { strip_doc_links( &lines @@ -310,17 +300,3 @@ pub fn docs(attrs: &[syn::Attribute]) -> Vec { } paragraphs } - -/// [`docs`] of the variant with discriminant `byte` in whichever discriminator -/// enum backs the given IDL `section`. -pub fn discriminator_variant_docs(section: Section, byte: u8) -> Vec { - let enum_name = match section { - Section::Instructions => "SettlementInstruction", - Section::Accounts => "SettlementAccount", - other => panic!("no discriminator enum backs IDL {other}[]"), - }; - - let rust_enum = INTERFACE_LIB_RS.find_enum(enum_name); - - docs(&variant_by_discriminant(&rust_enum, byte).attrs) -} diff --git a/programs/settlement/tests/idl/superset.rs b/programs/settlement/tests/idl/superset.rs new file mode 100644 index 00000000..c4b0ac04 --- /dev/null +++ b/programs/settlement/tests/idl/superset.rs @@ -0,0 +1,215 @@ +//! The comparison between the partial IDL the Rust source implies and the IDL +//! that's actually checked in. +//! +//! [`assert_superset`] holds the checked-in file to everything +//! [`crate::generate`] could derive, and to nothing more: an IDL key the +//! generated document doesn't mention is a fact the Rust source can't state, so +//! it's left for the schema and the human reviewer. That asymmetry is the whole +//! design — the generated side never has to be a complete IDL, only a true one. + +use serde_json::Value; + +use crate::parse_rust::normalize_doc; + +/// Asserts `actual` states everything `generated` does. Every mismatch is +/// collected before failing, so one run reports the full drift rather than +/// whichever entry happened to be compared first. +pub fn assert_superset(generated: &Value, actual: &Value) { + let mut problems = Vec::new(); + compare("", generated, actual, &mut problems); + assert!( + problems.is_empty(), + "the checked-in IDL doesn't agree with the Rust source it describes:\n\n{}\n", + problems.join("\n\n"), + ); +} + +/// How the two sides of an array are lined up. +enum ArrayRule { + /// Index `i` describes the same thing on both sides, and the arrays have to + /// be the same length. This is the rule for everything laid out on the + /// wire, where order and completeness are the point. + Positional, + /// Entries are matched by their `name`, in whatever order each side happens + /// to list them. `exhaustive` additionally rejects an entry in `actual` + /// that the generated side never matched. + ByName { exhaustive: bool }, +} + +/// The rule for the array at `path`. +fn array_rule(path: &str) -> ArrayRule { + match path { + // Top-level sections. The Rust source defines every entry, so one the + // IDL has left over from a rename, or invented outright, is an error — + // but the two are free to order them differently, and they do: the IDL + // reads top to bottom in call order where the discriminator enum counts + // from zero. + "instructions" | "accounts" | "types" | "errors" => ArrayRule::ByName { exhaustive: true }, + // An instruction's accounts. Only the ones whose PDA seeds the interface + // pins are generated, so the IDL legitimately names more, in an order + // nothing in the Rust source fixes. + _ if path.ends_with("].accounts") => ArrayRule::ByName { exhaustive: false }, + // Struct fields, instruction args, enum variants, discriminator bytes, + // PDA seeds and their const bytes: all wire order, all complete. + _ => ArrayRule::Positional, + } +} + +fn compare(path: &str, generated: &Value, actual: &Value, problems: &mut Vec) { + match generated { + Value::Object(generated) => { + let Some(actual) = actual.as_object() else { + problems.push(format!( + "{}: the IDL has {actual} where an object belongs", + at(path) + )); + return; + }; + for (key, generated) in generated { + let path = format!("{path}.{key}"); + let path = path.trim_start_matches('.'); + match actual.get(key) { + // `docs` is prose, and prose is the one thing the two sides + // are allowed to disagree on the shape of. + Some(actual) if key == "docs" => { + compare_docs(path, generated, actual, problems) + } + Some(actual) => compare(path, generated, actual, problems), + None => problems.push(format!( + "{}: missing from the IDL; the Rust source says it is\n {generated}", + at(path) + )), + } + } + } + Value::Array(generated) => { + let Some(actual) = actual.as_array() else { + problems.push(format!( + "{}: the IDL has {actual} where an array belongs", + at(path) + )); + return; + }; + match array_rule(path) { + ArrayRule::Positional => compare_positional(path, generated, actual, problems), + ArrayRule::ByName { exhaustive } => { + compare_by_name(path, generated, actual, exhaustive, problems); + } + } + } + scalar => { + if scalar != actual { + problems.push(format!( + "{}: the IDL says {actual}, the Rust source says {scalar}", + at(path) + )); + } + } + } +} + +fn compare_positional( + path: &str, + generated: &[Value], + actual: &[Value], + problems: &mut Vec, +) { + if generated.len() != actual.len() { + problems.push(format!( + "{}: the IDL lists {} entries, the Rust source has {}", + at(path), + actual.len(), + generated.len() + )); + } + for (index, generated) in generated.iter().enumerate() { + let path = format!("{path}[{index}]"); + match actual.get(index) { + Some(actual) => compare(&path, generated, actual, problems), + None => problems.push(format!( + "{}: missing from the IDL; the Rust source says it is\n {generated}", + at(&path) + )), + } + } +} + +fn compare_by_name( + path: &str, + generated: &[Value], + actual: &[Value], + exhaustive: bool, + problems: &mut Vec, +) { + for generated in generated { + let name = generated["name"] + .as_str() + .unwrap_or_else(|| panic!("{path} entries are matched by name, so each needs one")); + let path = format!("{path}[{name}]"); + match actual.iter().find(|entry| entry["name"] == *name) { + Some(actual) => compare(&path, generated, actual, problems), + None => problems.push(format!("{}: missing from the IDL", at(&path))), + } + } + + if exhaustive && actual.len() != generated.len() { + let unmatched: Vec<&str> = actual + .iter() + .filter_map(|entry| entry["name"].as_str()) + .filter(|name| !generated.iter().any(|entry| entry["name"] == **name)) + .collect(); + problems.push(format!( + "{}: the IDL carries entries with no counterpart in the Rust source: {unmatched:?}", + at(path) + )); + } +} + +/// Checks the IDL documents everything the Rust source does, in the same order. +/// The IDL may contain more docs. +fn compare_docs(path: &str, generated: &Value, actual: &Value, problems: &mut Vec) { + let (Some(generated), Some(actual)) = (generated.as_array(), actual.as_array()) else { + problems.push(format!( + "{}: the IDL has {actual} where docs belong", + at(path) + )); + return; + }; + + let lines: Vec<&str> = actual.iter().filter_map(Value::as_str).collect(); + if lines.len() != actual.len() { + problems.push(format!( + "{}: every IDL doc entry must be a string", + at(path) + )); + return; + } + // The IDL side goes through the same normalization the Rust side already + // did, so the backticks one carries and the other doesn't never show up as + // a difference in what they say. + let idl_prose = normalize_doc(&lines); + + // Each paragraph consumes the prose up to and including itself, so the next + // one can only match further along: that's what makes this an order check + // and not just a set of independent lookups. + let mut unread = idl_prose.as_str(); + for paragraph in generated.iter().filter_map(Value::as_str) { + match unread.split_once(paragraph) { + Some((_, after)) => unread = after, + None => problems.push(format!( + "{}: the IDL doesn't document this, or documents it out of order:\n \ + {paragraph}\nthe IDL's prose is:\n {idl_prose}", + at(path) + )), + } + } +} + +/// Names the position in the IDL a problem was found at. +fn at(path: &str) -> String { + if path.is_empty() { + "the IDL's root".to_string() + } else { + path.to_string() + } +}