diff --git a/Cargo.lock b/Cargo.lock index e72c592..5f0011f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -612,6 +613,12 @@ dependencies = [ "subtle", ] +[[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.8.0" @@ -688,6 +695,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[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.2" @@ -1263,6 +1276,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" @@ -1325,6 +1347,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.5.0" @@ -1394,6 +1427,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" @@ -1409,6 +1453,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 = "funty" version = "2.0.0" @@ -1970,6 +2024,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", + "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" @@ -2251,10 +2332,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 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.8", + "num-complex 0.4.6", "num-integer", "num-iter", - "num-rational", + "num-rational 0.4.2", "num-traits", ] @@ -2279,6 +2374,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" @@ -2289,6 +2390,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" @@ -2331,6 +2441,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.8", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2390,6 +2511,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 = "pairing" version = "0.23.0" @@ -2455,7 +2582,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -2809,6 +2936,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.13.1" @@ -3091,6 +3252,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -3140,12 +3302,14 @@ dependencies = [ "bincode", "cow-settlement-client", "cow-settlement-interface", + "jsonschema", "litesvm", "litesvm-token", "pinocchio", "pinocchio-system", "pinocchio-token", "proptest", + "serde_json", "solana-address-lookup-table-interface", "solana-compute-budget", "solana-instruction", @@ -3154,6 +3318,7 @@ dependencies = [ "solana-sdk", "solana-sha256-hasher", "solana-system-interface 3.2.0", + "syn 2.0.119", ] [[package]] @@ -5977,6 +6142,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" @@ -5989,6 +6175,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 6e24208..0dfafcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ solana-program-pack = "3" solana-rpc-client = "4" derive_more = { version = "1", features = ["deref"] } hex-literal = "1" +jsonschema = "0.30" litesvm = "0.15" litesvm-token = "0.15" num_enum = "0.7" @@ -34,6 +35,7 @@ pinocchio = "0.11" pinocchio-system = "0.6" pinocchio-token = "0.6" proptest = "1" +serde_json = { version = "1", features = ["preserve_order"] } cow-settlement-client = { path = "client", version = "0.2.0" } cow-settlement-interface = { path = "interface", version = "0.2.0" } solana-account-view = "2" @@ -57,6 +59,7 @@ solana-system-interface = "3" spl-associated-token-account-interface = "2" spl-token = "9" spl-token-interface = "3" +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 491f531..e611320 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/interface/src/lib.rs b/interface/src/lib.rs index 0e019aa..089ad2d 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, ReclaimBuffer = 6, TransferAuthority = 7, diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 7638cf5..76114bb 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -26,10 +26,12 @@ solana-instruction = { workspace = true, features = ["syscalls"] } [dev-dependencies] arrayref.workspace = true +jsonschema.workspace = true bincode.workspace = true litesvm.workspace = true litesvm-token.workspace = true proptest.workspace = true +serde_json.workspace = true cow-settlement-client.workspace = true cow-settlement-interface = { workspace = true, features = ["test-fixtures"] } solana-address-lookup-table-interface = { workspace = true, features = ["bincode"] } @@ -39,6 +41,7 @@ solana-program-pack.workspace = true solana-sdk.workspace = true solana-sha256-hasher.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 new file mode 100644 index 0000000..60865a4 --- /dev/null +++ b/programs/settlement/idl/cow_settlement.json @@ -0,0 +1,862 @@ +{ + "address": "J516Mv7YvvvJyMvNEca8tWNTJyDHbFpzwDZD96BNfR3w", + "metadata": { + "name": "cow_settlement", + "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" + }, + "instructions": [ + { + "name": "initialize", + "docs": [ + "Creates the singleton settlement state PDA. Succeeds only once." + ], + "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, + 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." + ] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "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 the initial holder of the ReclaimAuthority role: the account authorized to close buffer accounts and reclaim their rent." + ], + "type": "pubkey" + } + ] + }, + { + "name": "create_buffer", + "docs": [ + "Creates one or more per-token buffer PDAs (SPL token accounts) in a single 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": [ + 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, + 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: 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_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 + ], + "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, 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 + ], + "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": "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." + ] + } + ] + }, + { + "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.", + "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 + ], + "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": [ + { + "name": "begin_ix_index", + "type": "u16", + "docs": [ + "Index of the paired BeginSettle in this transaction. Little-endian, matching standard Borsh/Anchor u16 decoding." + ] + } + ] + }, + { + "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. Its canonical bump is read out of the stored OrderAccount body, so no bump argument is needed." + ] + }, + { + "name": "reclaim_recipient", + "writable": true, + "docs": [ + "Must match the created_by address recorded in the order; receives the recovered rent lamports." + ] + } + ], + "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": "role", + "type": { + "defined": { + "name": "Role" + } + }, + "docs": [ + "The role to transfer, as its single wire discriminator byte." + ] + }, + { + "name": "new_authority", + "type": "pubkey", + "docs": [ + "Becomes the holder of `role` once the instruction succeeds." + ] + } + ] + } + ], + "accounts": [ + { + "name": "OrderAccount", + "discriminator": [ + 128 + ] + }, + { + "name": "SettlementState", + "discriminator": [ + 129 + ] + } + ], + "types": [ + { + "name": "OrderAccount", + "docs": [ + "Idiomatic representation of an order PDA's body." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "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": [ + "Idiomatic representation of the state PDA's body." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "type": "pubkey" + }, + { + "name": "reclaim_authority", + "type": "pubkey" + } + ] + } + }, + { + "name": "OrderIntent", + "docs": [ + "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", + "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", + "docs": [ + "Direction of the trade." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Sell" + }, + { + "name": "Buy" + } + ] + } + }, + { + "name": "Role", + "docs": [ + "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", + "variants": [ + { + "name": "Manager" + }, + { + "name": "ReclaimAuthority" + } + ] + } + } + ], + "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": "AccountNotDerivable", + "msg": "An account was provided that cannot be derived from the seeds recognized by the program" + }, + { + "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: n orders each contribute an order PDA and a sell token account, plus one destination account per transfer." + }, + { + "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." + }, + { + "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": "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": 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." + } + ] +} 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 0000000..0eec2df --- /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-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", + "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/generate.rs b/programs/settlement/tests/idl/generate.rs new file mode 100644 index 0000000..c918efa --- /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 new file mode 100644 index 0000000..31ff063 --- /dev/null +++ b/programs/settlement/tests/idl/main.rs @@ -0,0 +1,116 @@ +//! 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. +//! +//! 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 generate; +mod parse_rust; +mod superset; + +use std::{fs, path::PathBuf, sync::LazyLock}; + +use serde_json::Value; + +const IDL_JSON: &str = include_str!("../../idl/cow_settlement.json"); +const SCHEMA_JSON: &str = include_str!("../../idl/schema/idl-spec-v0.1.0.json"); + +static IDL: LazyLock = + LazyLock::new(|| serde_json::from_str(IDL_JSON).expect("IDL must be valid JSON")); + +/// 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] +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_conforms_to_official_schema() { + 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 + // 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!( + errors.is_empty(), + "IDL fails schema validation:\n{}", + errors.join("\n") + ); +} + +/// 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_everything_generated_from_rust() { + let generated = generate::partial_idl(); + + 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()); + + 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 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 + ); + } + } + } +} diff --git a/programs/settlement/tests/idl/parse_rust.rs b/programs/settlement/tests/idl/parse_rust.rs new file mode 100644 index 0000000..c932e80 --- /dev/null +++ b/programs/settlement/tests/idl/parse_rust.rs @@ -0,0 +1,302 @@ +//! Readers for the Rust sources the IDL describes. +//! +//! 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}; + +/// 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"), +}; + +pub const INITIALIZE_RS: Source = Source { + display: "interface/src/instruction/initialize.rs", + 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"), +}; + +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 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"), +}; + +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)) + } +} + +/// 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()?.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 { + lit: syn::Lit::Int(len), + .. + }) = &array.len + else { + return None; + }; + let len: u64 = len.base10_parse().ok()?; + Some(json!({ "array": [try_type_to_idl(&array.elem)?, len] })) + } + _ => 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 { + match &variant.discriminant { + Some(( + _, + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(i), + .. + }), + )) => 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 => 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)) +} + +/// 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_name(field, context); + let ty = type_to_idl(&field.ty, &format!("{context}.{name}")); + json!({ "name": name, "type": ty }) + }) + .collect(); + json!({ "kind": "struct", "fields": 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() + .map(|variant| json!({ "name": variant.ident.to_string() })) + .collect(); + json!({ "kind": "enum", "variants": variants }) +} + +/// 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)` +/// 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 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 + .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 +} diff --git a/programs/settlement/tests/idl/superset.rs b/programs/settlement/tests/idl/superset.rs new file mode 100644 index 0000000..c4b0ac0 --- /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() + } +}