Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/target
proptest-regressions/
.cargo-root/
node_modules/
pnpm-lock.yaml
generated/
29 changes: 29 additions & 0 deletions programs/settlement/idl/client/js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "js-client",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"files": [
"./dist/src",
"./dist/types",
"./src/"
],
"scripts": {
"test": "vitest run"
},
"keywords": [],
"author": "",
"peerDependencies": {
"@solana/kit": "^6.10.0"
},
"dependencies": {
"@solana/program-client-core": "^6.10.0"
},
"devDependencies": {
"@solana/kit": "^6.10.0",
"@types/node": "^24.10.0",
"litesvm": "^1.3.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
34 changes: 34 additions & 0 deletions programs/settlement/idl/client/js/src/hooked.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Hand-written — Codama's "hooked" convention: functions referenced by a
// `resolverValueNode` default value live here, imported by generated code
// via the fixed "../../hooked" specifier (see generate.mjs). Never touched
// by rendering, which only manages src/generated.
//
// create_order's order PDA is seeded by [SETTLEMENT_SEED, sha256(intent_bytes), "order"].
// The middle seed is a hash of the whole `intent` argument rather than a plain
// field/account reference, which the Anchor PDA-seed grammar (const / arg / account)
// can't express — see cow_settlement.json's create_order docs — so it's handed off
// to this resolver instead of a plain `pda` node. Codama calls this with a
// `resolverScope` object and splices the returned `{ value }` onto the account
// it's resolving, so its shape is dictated by the renderer, not chosen here.

import { getProgramDerivedAddress, type Address } from "@solana/kit";
import { getOrderIntentEncoder, type OrderIntentArgs } from "./generated";
import { SETTLEMENT_SEED } from "./generated/settlementSeed";

export async function resolveOrderPda({
programAddress,
args,
}: {
programAddress: Address;
args: { intent: OrderIntentArgs };
}): Promise<{ value: Address }> {
const intentBytes = getOrderIntentEncoder().encode(args.intent);
const orderUid = new Uint8Array(
await crypto.subtle.digest("SHA-256", intentBytes),
);
const [address] = await getProgramDerivedAddress({
programAddress,
seeds: [SETTLEMENT_SEED, orderUid, "order"],
});
return { value: address };
}
1 change: 1 addition & 0 deletions programs/settlement/idl/client/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./generated";
78 changes: 78 additions & 0 deletions programs/settlement/idl/client/js/tests/createOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { LiteSVM } from "litesvm";
import { beforeEach, describe, expect, it } from "vitest";
import {
appendTransactionMessageInstruction,
assertAccountExists,
createTransactionMessage,
generateKeyPairSigner,
lamports,
pipe,
setTransactionMessageFeePayerSigner,
signTransactionMessageWithSigners,
} from "@solana/kit";
import {
COW_SETTLEMENT_PROGRAM_ADDRESS,
getCreateOrderInstructionAsync,
getOrderAccountDecoder,
} from "../src/generated";
import { resolveOrderPda } from "../src/hooked";
import { buildOrderIntent, COW_SETTLEMENT_SO_PATH } from "./fixtures";

describe("createOrder", () => {
let svm: LiteSVM;

beforeEach(() => {
svm = new LiteSVM();
svm.addProgramFromFile(COW_SETTLEMENT_PROGRAM_ADDRESS, COW_SETTLEMENT_SO_PATH);
});

it("creates an order account matching the submitted intent", async () => {
const owner = await generateKeyPairSigner();
svm.airdrop(owner.address, lamports(1_000_000_000n));

const intent = await buildOrderIntent({ owner: owner.address });
const instruction = await getCreateOrderInstructionAsync({
owner,
createdBy: owner,
intent,
});

const tx = await pipe(
createTransactionMessage({ version: 0 }),
(t) => setTransactionMessageFeePayerSigner(owner, t),
(t) => svm.setTransactionMessageLifetimeUsingLatestBlockhash(t),
(t) => appendTransactionMessageInstruction(instruction, t),
signTransactionMessageWithSigners,
);

// litesvm returns a native class, not a POJO: `err`/`meta` are methods, so
// `JSON.stringify(result.err)` would print `undefined` and hide the failure.
const result = svm.sendTransaction(tx);
if ("err" in result) {
throw new Error(
`createOrder failed: ${result.toString()}\n${result.meta().prettyLogs()}`,
);
}

const { value: orderPda } = await resolveOrderPda({
programAddress: COW_SETTLEMENT_PROGRAM_ADDRESS,
args: { intent },
});
// `getAccount` returns a `MaybeEncodedAccount`, never null: a missing
// account comes back as `{ exists: false }`, so it needs an explicit check.
const account = svm.getAccount(orderPda);
expect(account.exists).toBe(true);
assertAccountExists(account);

const decoded = getOrderAccountDecoder().decode(account.data);
expect(decoded.cancelled).toBe(false);
expect(decoded.amountWithdrawn).toBe(0n);
expect(decoded.amountReceived).toBe(0n);
expect(decoded.createdBy).toBe(owner.address);
expect(decoded.intent).toEqual({
...intent,
sellAmount: BigInt(intent.sellAmount),
buyAmount: BigInt(intent.buyAmount),
});
});
});
26 changes: 26 additions & 0 deletions programs/settlement/idl/client/js/tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import path from "node:path";
import { generateKeyPairSigner, type Address } from "@solana/kit";
import { OrderKind, type OrderIntentArgs } from "../src/generated";

export const COW_SETTLEMENT_SO_PATH = path.join(
import.meta.dirname,
"../../../../../../target/deploy/cow_settlement.so",
);

export async function buildOrderIntent(
overrides: Partial<OrderIntentArgs> & { owner: Address },
): Promise<OrderIntentArgs> {
const buyTokenAccount = await generateKeyPairSigner();
const sellTokenAccount = await generateKeyPairSigner();
return {
buyTokenAccount: buyTokenAccount.address,
sellTokenAccount: sellTokenAccount.address,
sellAmount: 1_000_000n,
buyAmount: 2_000_000n,
validTo: Math.floor(Date.now() / 1000) + 3600,
kind: OrderKind.Sell,
partiallyFillable: false,
appData: new Uint8Array(32),
...overrides,
};
}
12 changes: 12 additions & 0 deletions programs/settlement/idl/client/js/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["src", "tests"]
}
67 changes: 67 additions & 0 deletions programs/settlement/idl/generate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { writeFileSync, mkdirSync } from "node:fs";
import {
createFromRoot,
resolverValueNode,
argumentValueNode,
setInstructionAccountDefaultValuesVisitor,
} from "codama";
import { rootNodeFromAnchor } from "@codama/nodes-from-anchor";
import { renderVisitor } from "@codama/renderers-js";
import IDL from "./cow_settlement.json" with {type: 'json'};

const codama = createFromRoot(rootNodeFromAnchor(IDL));

// order_pda seed generation requires hashing the input intent in `createOrder`
// so we use codama's `resolverValueNode` to inject custom code for this.
codama.update(
setInstructionAccountDefaultValuesVisitor([
{
instruction: "createOrder",
account: "orderPda",
defaultValue: resolverValueNode("resolveOrderPda", {
dependsOn: [argumentValueNode("intent")],
}),
},
]),
);

codama.accept(
renderVisitor("./client/js", {
// resolveOrderPda hashes with crypto.subtle.digest, which is async, so
// codama must await it and only wire it into the *Async instruction
// builder rather than the sync one.
asyncResolvers: ["resolveOrderPda"],
}),
);

// codama inlines each PDA's const seeds into the function that derives it and
// exports no constant for them, so resolveOrderPda — which builds the order PDA
// seeds by hand — has nothing to import. Emit SETTLEMENT_SEED here, read off the
// state PDA's sole const seed, so the version-dependent bytes still have exactly
// one source (the IDL) and follow it across version bumps. Written after the
// render because renderVisitor wipes its output directory first.
const settlementSeed = IDL.instructions
.find((instruction) => instruction.name === "initialize")
.accounts.find((account) => account.name === "state_pda").pda.seeds[0].value;

mkdirSync("./client/js/src/generated", { recursive: true });

writeFileSync(
"./client/js/src/generated/settlementSeed.ts",
`/**
* This code was AUTOGENERATED by generate.mjs (not by Codama itself, which
* inlines PDA seeds into its derivation functions instead of exporting them).
* Please DO NOT EDIT THIS FILE, edit generate.mjs and rerun it instead.
*/

/**
* Prefix seed shared by every PDA this program derives: the ASCII "settlement v"
* followed by the program's major.minor version, right-padded with spaces to a
* fixed 19 bytes. The fixed width keeps one version's seeds from being a prefix
* of another's. These bytes change on every minor version bump.
*/
export const SETTLEMENT_SEED: Uint8Array = new Uint8Array([
${settlementSeed.join(", ")},
]);
`,
);
18 changes: 18 additions & 0 deletions programs/settlement/idl/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "idl",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.30.3",
"dependencies": {
"@codama/nodes-from-anchor": "^1.5.2",
"@codama/renderers-js": "^2.3.0",
"codama": "^1.9.0"
}
}
Loading