diff --git a/Cargo.lock b/Cargo.lock index 260ca88..1b0d229 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 8416e73..d2e2dbb 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.3.0" } cow-settlement-interface = { path = "interface", version = "0.3.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 3967ed6..d0f5b9a 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -103,6 +103,9 @@ impl TryFrom<[u8; 1]> for Flags { } } +/// 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 5952479..d63ea93 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 723eeb5..20afb9e 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..bb11b0d --- /dev/null +++ b/programs/settlement/idl/cow_settlement.json @@ -0,0 +1,971 @@ +{ + "address": "FYp8R5K4B3B1Kfr7QuWzMz4TwoT7wptjYtxgCrY5sRXb", + "metadata": { + "name": "cow_settlement", + "version": "0.3.0", + "spec": "0.1.0", + "description": "CoW Protocol settlement program. Some of the properties and instructions of this program can't be represented via a standard IDL, please be careful to read the documentation on the function you are using. Both the program and this IDL are in early release phase and may contain errors.", + "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, + 51, + 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." + ] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "manager", + "docs": [ + "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": [ + "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, + 51, + 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.", + "The intent's created_on_chain flag must be set: this instruction implements that authentication scheme, and an intent flagged for off-chain Ed25519 authentication is rejected." + ], + "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": "solver", + "signer": true, + "docs": [ + "The solver settling this batch. Must be present in the state PDA's solver list; only a registered solver may settle." + ] + }, + { + "name": "instructions_sysvar", + "address": "Sysvar1nstructions1111111111111111111111111" + }, + { + "name": "state_pda", + "docs": [ + "Must be the canonical state PDA; holds the solver list `solver` is checked against, and 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.", + "An order created on-chain is reclaimable earlier than that: as soon as it has been cancelled or completely filled, without waiting for valid_to.", + "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, + 51, + 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." + ] + }, + { + "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, + 51, + 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, + 51, + 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." + ] + } + ], + "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." + ] + } + ] + }, + { + "name": "add_solver", + "docs": [ + "Registers a solver, inserting it into the sorted solver list stored in the state PDA. Only the current holder of the Manager role may authorize this, and a solver that is already registered is rejected.", + "The state PDA grows by one address, so the payer tops up its rent through a system-program transfer." + ], + "discriminator": [ + 8 + ], + "accounts": [ + { + "name": "manager", + "signer": true, + "docs": [ + "Must match the manager address recorded in the state PDA. Authorizes the change but doesn't pay for it." + ] + }, + { + "name": "payer", + "writable": true, + "signer": true, + "docs": [ + "Funds the rent of the state PDA's extra solver slot." + ] + }, + { + "name": "state_pda", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 116, + 116, + 108, + 101, + 109, + 101, + 110, + 116, + 32, + 118, + 48, + 46, + 51, + 32, + 32, + 32, + 32 + ] + } + ] + }, + "docs": [ + "Holds the manager the signer is checked against and the solver list the new solver is inserted into; grown by one address. 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." + ] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "solver", + "type": "pubkey", + "docs": [ + "The account to register as a solver." + ] + } + ] + } + ], + "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": [ + "The parameters used to initialize the state account. They are also the account's stored header, in wire order.", + "IDL LIMITATION: the sorted list of approved solvers that follows the header isn't declared here. It's a bare run of 32-byte addresses filling the rest of the account, with no length prefix for a vec type to read. add_solver inserts into it, keeping it sorted ascending by address so that it stays binary-searchable." + ], + "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": "buy_mint", + "type": "pubkey" + }, + { + "name": "sell_token_account", + "type": "pubkey" + }, + { + "name": "sell_mint", + "type": "pubkey" + }, + { + "name": "sell_amount", + "type": "u64" + }, + { + "name": "buy_amount", + "type": "u64" + }, + { + "name": "valid_to", + "type": "u32" + }, + { + "name": "flags", + "type": "u8", + "docs": [ + "IDL LIMITATION: this byte packs three settings the IDL's type grammar can't name as one field, so it's declared as the raw byte the wire carries. Bit 0 is created_on_chain: set when the order is authenticated by its owner signing a create_order instruction, clear when it's authenticated by an off-chain Ed25519 signature. Bit 1 is the OrderKind (0 = Sell, 1 = Buy). Bit 2 is partially_fillable.", + "Every other bit is reserved and must be clear: the program rejects a flags byte carrying one, so the same settings never have two encodings, and an order never has two UIDs." + ] + }, + { + "name": "app_data", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "OrderKind", + "docs": [ + "Direction of the trade. The discriminants are the values the kind bit of the encoded flags byte takes." + ], + "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": "BeginSettle: a paired FinalizeSettle push doesn't draw funds from the canonical buffer for the order's buy_mint." + }, + { + "code": 23, + "name": "SellMintMismatch", + "msg": "BeginSettle: the OrderIntent sell_token_account holds a different mint than the declared sell_mint." + }, + { + "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": "OrderNotReclaimable", + "msg": "ReclaimOrder was called on an order that has is not yet eligible for reclaim." + }, + { + "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." + }, + { + "code": 35, + "name": "UnauthorizedSolverManagement", + "msg": "AddSolver's manager account isn't a signer, or doesn't match the manager recorded in the settlement state PDA." + }, + { + "code": 36, + "name": "SolverAlreadyExists", + "msg": "AddSolver's solver is already in the state PDA's solver list." + }, + { + "code": 37, + "name": "UnauthorizedSolver", + "msg": "BeginSettle/FinalizeSettle's solver account isn't a signer or isn't in the state PDA's solver list, so it may not settle." + }, + { + "code": 38, + "name": "OrderCreatedOnChainMismatch", + "msg": "A created order's intent isn't set with the created_on_chain flag corresponding to the behavior of the invoked order creation instruction." + } + ] +} 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..8921156 --- /dev/null +++ b/programs/settlement/tests/idl/generate.rs @@ -0,0 +1,364 @@ +//! 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. + +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)], + }, + Instruction { + variant: SettlementInstruction::AddSolver, + input: &parse_rust::ADD_SOLVER_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. +const STRUCT_TYPES: &[(&Source, &str, &str)] = &[ + (&parse_rust::ORDER_RS, "OrderAccount", "OrderAccount"), + (&parse_rust::STATE_RS, "StateInitArgs", "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, [`field_override`]. +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 field_override(&input_name, &name) { + Some(overridden) => overridden, + 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, keyed by the struct declaring the field and the field's Rust name. +fn field_override(owner: &str, field: &str) -> Option<(String, Value)> { + match (owner, field) { + // The wire carries the canonical intent bytes; the IDL names the type + // they decode to. + ("CreateOrderInput", "intent_bytes") => Some(( + "intent".to_string(), + json!({ "defined": { "name": "OrderIntent" } }), + )), + // `Flags` packs three fields into a single byte, which the IDL's type + // grammar can't express. The byte is what the wire carries. + ("OrderIntent", "flags") => Some(("flags".to_string(), json!("u8"))), + _ => None, + } +} + +/// 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, and [`field_override`] applied to each. +fn struct_type(rust_struct: &syn::ItemStruct, rust_name: &str) -> Value { + let fields: Vec = rust_struct + .fields + .iter() + .map(|field| { + let name = parse_rust::field_name(field, rust_name); + let (name, ty) = match field_override(rust_name, &name) { + Some(overridden) => overridden, + None => { + let ty = parse_rust::type_to_idl(&field.ty, &format!("{rust_name}.{name}")); + (name, ty) + } + }; + json!({ "name": name, "type": ty }) + }) + .collect(); + json!({ "kind": "struct", "fields": fields }) +} + +/// 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), + 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..a21dfac --- /dev/null +++ b/programs/settlement/tests/idl/main.rs @@ -0,0 +1,117 @@ +//! 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 specification provides no way to specify the numeric value +/// of enum variants. If an enum is used as an input for an instruction or +/// account encoding (such as with OrderKind), a mismatch in the order +/// in the IDL would lead to mis-encoding. This test prevents them from +/// happening by making the ordering of variants sensitive. +#[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..35ca0e9 --- /dev/null +++ b/programs/settlement/tests/idl/parse_rust.rs @@ -0,0 +1,309 @@ +//! 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"), +}; + +pub const ADD_SOLVER_RS: Source = Source { + display: "interface/src/instruction/add_solver.rs", + text: include_str!("../../../../interface/src/instruction/add_solver.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)) +} + +/// An enum as an IDL `types[]` entry's `type`: `{"kind": "enum", "variants": +/// [...]}`, with the variants in declaration order. +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 { + fn split_link(text: &str) -> Option<(&str, &str)> { + let (display, after_display) = text.strip_prefix('[')?.split_once("]")?; + + if let Some((target, after_link)) = after_display + .strip_prefix("(") + .and_then(|f| f.split_once(')')) + { + (!display.contains('[') && !target.contains('(')).then_some((display, after_link)) + } else { + (!display.contains("[")).then_some((display, after_display)) + } + } + + 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 +} + +mod tests { + use crate::parse_rust::strip_doc_links; + + #[test] + fn test_link_stripping() { + assert_eq!( + strip_doc_links("There are two ixs, [Begin](begin) and [Finalize](finalize)"), + "There are two ixs, Begin and Finalize" + ); + + assert_eq!( + strip_doc_links("Testing a [standalone] link"), + "Testing a standalone link" + ) + } +} diff --git a/programs/settlement/tests/idl/superset.rs b/programs/settlement/tests/idl/superset.rs new file mode 100644 index 0000000..42b5070 --- /dev/null +++ b/programs/settlement/tests/idl/superset.rs @@ -0,0 +1,213 @@ +//! The comparison between the partial IDL the Rust source implies and the IDL +//! that's actually checked in. +//! +//! [`assert_superset`] checks that the checked-in IDL is consistent with the +//! JSON generated by crate::generate. The checked-in version may contain more +//! data than what has been generated, but the available information cannot be different. + +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() + } +}