Skip to content

Commit 032f321

Browse files
authored
Merge pull request #61 from quicknode/claude/shank-codama
tools: modernize shank example from Solita to Codama; re-enable in CI
2 parents d22b3e1 + 929564e commit 032f321

55 files changed

Lines changed: 5310 additions & 1305 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/.ghaignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# uses generated client from shank, can't rewrite to solana-bankrun
2-
tools/shank-and-solita/native
3-
41
# build failed - program outdated
52
tokens/token-extensions/metadata/anchor
63

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ node_modules/
1111
# Exception: escrow native is a standalone (non-workspace) crate, so it keeps
1212
# its Cargo.lock tracked for reproducible builds.
1313
!finance/escrow/native/Cargo.lock
14+
# Exception: shank-and-codama native is also a standalone (non-workspace) crate.
15+
!tools/shank-and-codama/native/program/Cargo.lock
1416

1517
**/*/.anchor
1618
**/*/.DS_Store
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Shank and Codama
2+
3+
[Shank](https://github.com/metaplex-foundation/shank) lets a **native** Solana
4+
[program](https://solana.com/docs/terminology#program) export an IDL the same
5+
way [Anchor](https://solana.com/docs/terminology#anchor) does. Once you have an
6+
IDL, [Codama](https://github.com/codama-idl/codama) turns it into a typed client
7+
in the language of your choice.
8+
9+
This example is a small "car rental service" program. It is annotated with Shank
10+
macros, Shank extracts the IDL, and Codama renders a TypeScript client
11+
(`@solana/kit`-based) from that IDL. An in-process [LiteSVM](https://github.com/litesvm/litesvm)
12+
test then drives the program through the generated client — no validator or
13+
devnet required, so it runs in CI.
14+
15+
> This example used to use [Solita](https://github.com/metaplex-foundation/solita)
16+
> to generate the client. Solita is unmaintained and does not work on the current
17+
> toolchain, so it has been replaced with Codama. The Shank half of the lesson is
18+
> unchanged.
19+
20+
## Shank
21+
22+
[Shank](https://github.com/metaplex-foundation/shank) is a set of Rust derive
23+
macros plus a CLI that generates an IDL for your program.
24+
25+
Mark a struct as an [account](https://solana.com/docs/terminology#account):
26+
27+
```rust
28+
#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, ShankAccount)]
29+
pub struct Car {
30+
pub year: u16,
31+
pub make: String,
32+
pub model: String,
33+
}
34+
```
35+
36+
Mark an enum as your [instruction](https://solana.com/docs/terminology#instruction) set,
37+
using `#[account(...)]` attributes to describe each instruction's accounts:
38+
39+
```rust
40+
#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, ShankInstruction)]
41+
pub enum CarRentalServiceInstruction {
42+
#[account(0, writable, name = "car_account", desc = "The account that will represent the Car being created")]
43+
#[account(1, writable, name = "payer", desc = "Fee payer")]
44+
#[account(2, name = "system_program", desc = "The System Program")]
45+
AddCar(AddCarArgs),
46+
// ...
47+
}
48+
```
49+
50+
> Shank needs `declare_id!` in your program for the IDL generation to work:
51+
>
52+
> ```rust
53+
> declare_id!("8avNGHVXDwsELJaWMSoUZ44CirQd4zyU9Ez4ZmP4jNjZ");
54+
> ```
55+
56+
Install the CLI and generate the IDL:
57+
58+
```bash
59+
cargo install shank-cli
60+
pnpm generate-idl # runs: shank idl --crate-root ./program --out-dir ./program/idl
61+
```
62+
63+
The IDL lands in `program/idl/car_rental_service.json` (committed to the repo so
64+
the client can be regenerated without the Rust CLI). Its `metadata.origin` is
65+
`"shank"`, and each instruction carries an explicit single-byte (`u8`)
66+
`discriminant` — this is what distinguishes a Shank IDL from an Anchor IDL.
67+
68+
### A note on PDAs and `#[seeds(...)]`
69+
70+
Shank 0.0.x used a `#[seeds(...)]` attribute on a `ShankAccount` to *generate*
71+
`shank_pda` / `shank_seeds_with_bump` helper methods. As of Shank 0.4.x that PDA
72+
code-generation produces unparsable tokens and fails to compile, and the seeds
73+
are not emitted into the IDL either. So this example keeps PDA derivation
74+
explicit in `program/src/state/mod.rs` (`Car::find_pda`, `RentalOrder::find_pda`)
75+
and no longer uses the `#[seeds(...)]` attribute. `ShankAccount` is still used —
76+
it is what tells Shank to include the account layout in the IDL.
77+
78+
## Codama
79+
80+
[Codama](https://github.com/codama-idl/codama) reads an IDL and renders a client.
81+
It understands Shank IDLs out of the box.
82+
83+
Install the pieces used here:
84+
85+
```bash
86+
pnpm add codama @codama/nodes-from-anchor @codama/renderers-js @solana/kit
87+
```
88+
89+
The generator script ([`codama.ts`](./codama.ts)) reads the Shank IDL, sets its
90+
`origin` to `"shank"` so the `u8` discriminants are honoured, builds a Codama
91+
root node, and renders a TypeScript client:
92+
93+
```ts
94+
import { rootNodeFromAnchor } from "@codama/nodes-from-anchor";
95+
import { renderVisitor } from "@codama/renderers-js";
96+
import { createFromRoot } from "codama";
97+
98+
const idl = JSON.parse(readFileSync(idlPath, "utf-8"));
99+
const codama = createFromRoot(
100+
rootNodeFromAnchor({ ...idl, metadata: { ...idl.metadata, origin: "shank" } }),
101+
);
102+
await codama.accept(renderVisitor(outDir, { deleteFolderBeforeRendering: true }));
103+
```
104+
105+
> Codama also ships `@codama/renderers-rust` if you want a Rust client instead of
106+
> a TypeScript one — swap `renderVisitor` from `@codama/renderers-js` for the Rust
107+
> renderer.
108+
109+
Generate the client:
110+
111+
```bash
112+
pnpm generate-client
113+
```
114+
115+
The generated TypeScript client lands in `tests/generated/`.
116+
117+
## Build and test
118+
119+
```bash
120+
pnpm install
121+
pnpm build # cargo build-sbf -> program/target/so/car_rental_service.so
122+
pnpm build-and-test # build, regenerate the client, then run the LiteSVM test
123+
```
124+
125+
The test ([`tests/test.ts`](./tests/test.ts)) loads the compiled `.so` into a
126+
[LiteSVM](https://github.com/litesvm/litesvm) instance and exercises `add_car`,
127+
`book_rental`, and `pick_up_car` through the generated client, asserting on the
128+
resulting on-chain account state.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Codama client generator.
2+
//
3+
// Reads the Shank-generated IDL (program/idl/car_rental_service.json) and emits
4+
// a TypeScript client built on @solana/kit into tests/generated/.
5+
//
6+
// Flow: read IDL -> rootNodeFromAnchor (origin = "shank" so the u8 instruction
7+
// discriminants are interpreted correctly) -> createFromRoot -> render JS.
8+
//
9+
// Run with: pnpm generate-client
10+
11+
import { readFileSync, rmSync } from "node:fs";
12+
import { dirname, join } from "node:path";
13+
import { fileURLToPath } from "node:url";
14+
15+
import { type AnchorIdl, rootNodeFromAnchor } from "@codama/nodes-from-anchor";
16+
import { renderVisitor } from "@codama/renderers-js";
17+
import { createFromRoot } from "codama";
18+
19+
const here = dirname(fileURLToPath(import.meta.url));
20+
const idlPath = join(here, "program", "idl", "car_rental_service.json");
21+
const outDir = join(here, "tests", "generated");
22+
23+
const idl = JSON.parse(readFileSync(idlPath, "utf-8")) as AnchorIdl;
24+
25+
// Make sure Codama treats this as a Shank IDL. Shank uses single-byte (u8)
26+
// instruction discriminants rather than Anchor's 8-byte hashes, and the
27+
// "origin" field is what tells nodes-from-anchor to honour the explicit
28+
// `discriminant` values in the IDL.
29+
const idlWithOrigin = {
30+
...idl,
31+
metadata: { ...idl.metadata, origin: "shank" },
32+
} as AnchorIdl;
33+
34+
const codama = createFromRoot(rootNodeFromAnchor(idlWithOrigin));
35+
36+
await codama.accept(renderVisitor(outDir, { deleteFolderBeforeRendering: true }));
37+
38+
// The renderer drops a standalone `package.json` (declaring an implicit CommonJS
39+
// package) at the output root. That would shadow this example's
40+
// `"type": "module"` setting and break ESM resolution of the generated `.ts`
41+
// files when the test imports them via tsx, so remove it.
42+
rmSync(join(outDir, "package.json"), { force: true });
43+
44+
console.log(`Codama: generated TypeScript client in ${outDir}`);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"type": "module",
3+
"scripts": {
4+
"build": "cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./program/target/so",
5+
"generate-idl": "shank idl --crate-root ./program --out-dir ./program/idl",
6+
"generate-client": "tsx ./codama.ts",
7+
"test": "node --import tsx --test ./tests/test.ts",
8+
"build-and-test": "pnpm build && pnpm generate-client && pnpm test"
9+
},
10+
"dependencies": {
11+
"@codama/nodes-from-anchor": "^1.5.0",
12+
"@codama/renderers-js": "^2.2.0",
13+
"@solana-program/system": "^0.12.2",
14+
"@solana/program-client-core": "^6.9.0",
15+
"@solana/kit": "^6.9.0",
16+
"codama": "^1.7.0",
17+
"litesvm": "^1.1.0"
18+
},
19+
"devDependencies": {
20+
"@types/node": "^25.9.1",
21+
"tsx": "^4.22.4",
22+
"typescript": "^5.9.0"
23+
}
24+
}

0 commit comments

Comments
 (0)