diff --git a/.gitignore b/.gitignore index c812024..4157890 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /target proptest-regressions/ .cargo-root/ +node_modules/ +pnpm-lock.yaml +generated/ \ No newline at end of file diff --git a/programs/settlement/idl/client/js/package.json b/programs/settlement/idl/client/js/package.json new file mode 100644 index 0000000..298012c --- /dev/null +++ b/programs/settlement/idl/client/js/package.json @@ -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" + } +} diff --git a/programs/settlement/idl/client/js/src/hooked.ts b/programs/settlement/idl/client/js/src/hooked.ts new file mode 100644 index 0000000..b36c25c --- /dev/null +++ b/programs/settlement/idl/client/js/src/hooked.ts @@ -0,0 +1,33 @@ +// 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", sha256(borsh(intent)), "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"; + +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", orderUid, "order"], + }); + return { value: address }; +} diff --git a/programs/settlement/idl/client/js/src/index.ts b/programs/settlement/idl/client/js/src/index.ts new file mode 100644 index 0000000..e84c86c --- /dev/null +++ b/programs/settlement/idl/client/js/src/index.ts @@ -0,0 +1 @@ +export * from "./generated"; diff --git a/programs/settlement/idl/client/js/tests/createOrder.test.ts b/programs/settlement/idl/client/js/tests/createOrder.test.ts new file mode 100644 index 0000000..8ee88ab --- /dev/null +++ b/programs/settlement/idl/client/js/tests/createOrder.test.ts @@ -0,0 +1,75 @@ +import { LiteSVM } from "litesvm"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + appendTransactionMessageInstruction, + createTransactionMessage, + generateKeyPairSigner, + lamports, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + 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) => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: svm.latestBlockhash(), lastValidBlockHeight: 999_999_999n }, + t, + ), + (t) => appendTransactionMessageInstruction(instruction, t), + signTransactionMessageWithSigners, + ); + + const result = svm.sendTransaction(tx); + if ("err" in result) { + throw new Error(`createOrder failed: ${JSON.stringify(result.err)}`); + } + + const { value: orderPda } = await resolveOrderPda({ + programAddress: COW_SETTLEMENT_PROGRAM_ADDRESS, + args: { intent }, + }); + const account = svm.getAccount(orderPda); + expect(account).not.toBeNull(); + + 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), + }); + }); +}); diff --git a/programs/settlement/idl/client/js/tests/fixtures.ts b/programs/settlement/idl/client/js/tests/fixtures.ts new file mode 100644 index 0000000..c6b7721 --- /dev/null +++ b/programs/settlement/idl/client/js/tests/fixtures.ts @@ -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 & { owner: Address }, +): Promise { + 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, + }; +} diff --git a/programs/settlement/idl/client/js/tsconfig.json b/programs/settlement/idl/client/js/tsconfig.json new file mode 100644 index 0000000..991173d --- /dev/null +++ b/programs/settlement/idl/client/js/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"] + }, + "include": ["src", "tests"] +} diff --git a/programs/settlement/idl/generate.mjs b/programs/settlement/idl/generate.mjs new file mode 100644 index 0000000..0cf3bb0 --- /dev/null +++ b/programs/settlement/idl/generate.mjs @@ -0,0 +1,34 @@ +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"], + }), +); diff --git a/programs/settlement/idl/package.json b/programs/settlement/idl/package.json new file mode 100644 index 0000000..3179784 --- /dev/null +++ b/programs/settlement/idl/package.json @@ -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" + } +}