Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 0 additions & 1 deletion ts/packages/anchor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"bn.js": "^5.1.2",
"bs58": "^4.0.1",
"buffer-layout": "^1.2.2",
"camelcase": "^6.3.0",
"cross-fetch": "^3.1.5",
"eventemitter3": "^4.0.7",
"pako": "^2.0.3",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/anchor/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"@noble/hashes/utils",
Expand Down
15 changes: 10 additions & 5 deletions ts/packages/anchor/src/idl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { bs58, utf8 } from "./utils/bytes/index.js";
import { inflate, ungzip } from "pako";
import camelCase from "camelcase";
import { toCamelCase } from "./utils/case.js";
import { Buffer } from "buffer";
import { PublicKey } from "@solana/web3.js";

Expand Down Expand Up @@ -520,18 +520,23 @@ export function convertIdlToCamelCase<I extends Idl>(idl: I) {
const KEYS_TO_CONVERT = ["name", "path", "account", "relations", "generic"];

// `my_account.field` is getting converted to `myAccountField` but we
// need `myAccount.field`.
const toCamelCase = (s: any) =>
// need `myAccount.field`, so camelCase each dot-separated segment in
// isolation. The local helper has a distinct name from the imported
// `toCamelCase` to avoid the shadowing that would otherwise turn the
// self-reference below into infinite recursion.
const toCamelCasePath = (s: any) =>
s
.split(".")
.map((part: any) => camelCase(part, { locale: false }))
.map((part: any) => toCamelCase(part))
.join(".");

const recursivelyConvertNamesToCamelCase = (obj: Record<string, any>) => {
for (const key in obj) {
const val = obj[key];
if (KEYS_TO_CONVERT.includes(key)) {
obj[key] = Array.isArray(val) ? val.map(toCamelCase) : toCamelCase(val);
obj[key] = Array.isArray(val)
? val.map(toCamelCasePath)
: toCamelCasePath(val);
} else if (typeof val === "object") {
recursivelyConvertNamesToCamelCase(val);
}
Expand Down
27 changes: 27 additions & 0 deletions ts/packages/anchor/src/utils/case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Convert an identifier to `lowerCamelCase`, matching the Rust-side
* `heck::to_lower_camel_case` used during IDL generation. Unlike the npm
* `camelcase` package, digit-letter transitions don't introduce a new word,
* so `a1bReceive` stays `a1bReceive` instead of becoming `a1BReceive`.
* See https://github.com/otter-sec/anchor/issues/3043.
*/
export function toCamelCase(input: string): string {
if (!input) return input;

const spaced = input
.replace(/[^A-Za-z0-9]+/g, " ")
.replace(/([a-z][\d]*)([A-Z])/g, "$1 $2")
.replace(/([A-Z][A-Z\d]*)([A-Z][a-z])/g, "$1 $2")
.trim();

if (!spaced) return "";

return spaced
.split(/\s+/)
.map((word, i) =>
i === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join("");
}
8 changes: 4 additions & 4 deletions ts/packages/anchor/src/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as fs from "fs";
import * as path from "path";
import * as toml from "toml";
import camelcase from "camelcase";
Comment thread
swaroop-osec marked this conversation as resolved.
import { execSync } from "child_process";
import { Program } from "./program/index.js";
import { isBrowser } from "./utils/common.js";
import { toCamelCase } from "./utils/case.js";
import { Idl } from "./idl.js";

let cargoTargetDirectoryCache: string | undefined;
Expand Down Expand Up @@ -37,7 +37,7 @@ export function resolveIdlFileName(
);

const fileName = jsonFiles.find(
(name: string) => camelcase(path.parse(name).name) === programName
(name: string) => toCamelCase(path.parse(name).name) === programName
);

if (!fileName) {
Expand Down Expand Up @@ -102,7 +102,7 @@ const workspace = new Proxy(
// Converting `programName` to camelCase enables the ability to use any
// of the following to access the workspace program:
// `workspace.myProgram`, `workspace.MyProgram`, `workspace["my-program"]`...
programName = camelcase(programName);
programName = toCamelCase(programName);

// Return early if the program is in cache
if (workspaceCache[programName]) return workspaceCache[programName];
Expand All @@ -114,7 +114,7 @@ const workspace = new Proxy(
let programEntry;
if (programs) {
programEntry = Object.entries(programs).find(
([key]) => camelcase(key) === programName
([key]) => toCamelCase(key) === programName
)?.[1];
}

Expand Down
114 changes: 114 additions & 0 deletions ts/packages/anchor/tests/case.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { toCamelCase } from "../src/utils/case";
import { convertIdlToCamelCase, Idl } from "../src/idl";

// `structuredClone` ships natively in Node 17+, but jest 27's node test
// environment doesn't expose it on `globalThis`. Anchor's runtime targets
// Node >= 17, so polyfilling here just unblocks the test harness — the
// production code path uses the real built-in.
if (typeof (globalThis as any).structuredClone !== "function") {
(globalThis as any).structuredClone = (v: unknown) =>
JSON.parse(JSON.stringify(v));
}

// The npm `camelcase` library treats a digit followed by a letter as a word
// boundary (e.g. `a1bReceive` -> `a1BReceive`), but the Rust-side IDL
// generator uses `heck::to_lower_camel_case`, which does not split on
// digit-letter transitions. The mismatch caused TS-typed lookups for names
// like `a1bReceive` / `myA1bParam` to miss their runtime entries — which
// in the instruction-arg case silently defaulted to the zero pubkey.
// `toCamelCase` matches the Rust-side semantics.
describe("toCamelCase", () => {
test("converts snake_case to camelCase", () => {
expect(toCamelCase("foo_bar")).toBe("fooBar");
expect(toCamelCase("set_pass_threshold_bps")).toBe("setPassThresholdBps");
});

test("is idempotent on camelCase input", () => {
expect(toCamelCase("fooBar")).toBe("fooBar");
expect(toCamelCase("setPassThresholdBps")).toBe("setPassThresholdBps");
});

test("does not split on digit-letter transitions (#3043)", () => {
expect(toCamelCase("a1b_receive")).toBe("a1bReceive");
expect(toCamelCase("a1bReceive")).toBe("a1bReceive");
expect(toCamelCase("my_a1b_param")).toBe("myA1bParam");
expect(toCamelCase("myA1bParam")).toBe("myA1bParam");
});

test("digit-then-uppercase only splits when preceded by lowercase", () => {
expect(toCamelCase("A1B")).toBe("a1b"); // not "a1B"
expect(toCamelCase("XML1B")).toBe("xml1b"); // not "xml1B"
expect(toCamelCase("A1b")).toBe("a1b");
// Sanity: the lowercase-preceded case still splits.
expect(toCamelCase("a1B")).toBe("a1B");
});

test("acronym + digit + uppercase + lowercase still splits", () => {
expect(toCamelCase("XML1Http")).toBe("xml1Http"); // not "xml1http"
expect(toCamelCase("X1Foo")).toBe("x1Foo"); // not "x1foo"
});

test("handles leading and trailing digits", () => {
expect(toCamelCase("foo123")).toBe("foo123");
expect(toCamelCase("foo_123")).toBe("foo123");
expect(toCamelCase("123_foo")).toBe("123Foo");
});

test("handles acronyms as a single word", () => {
// `ABCFoo` -> [`ABC`, `Foo`] -> `abcFoo`
expect(toCamelCase("ABCFoo")).toBe("abcFoo");
expect(toCamelCase("ABC_foo")).toBe("abcFoo");
});

test("accepts hyphen / space / dot separators", () => {
expect(toCamelCase("foo-bar")).toBe("fooBar");
expect(toCamelCase("foo bar")).toBe("fooBar");
expect(toCamelCase("foo.bar")).toBe("fooBar");
});

test("handles single word and empty input", () => {
expect(toCamelCase("initialize")).toBe("initialize");
expect(toCamelCase("")).toBe("");
});
});

describe("convertIdlToCamelCase", () => {
// Regression guard: an earlier iteration of #3043's fix introduced a
// local helper named `toCamelCase` inside `convertIdlToCamelCase` that
// shadowed the imported `toCamelCase`, turning its self-reference into
// infinite recursion. Anchor's full CI matrix (tests/sysvars, escrow,
// misc, ...) blew the call stack with `at toCamelCase (idl.js:210)`
// repeated. The helper is now `toCamelCasePath` — this test exercises
// every code path that fans into it and asserts it terminates.
test("camelCases dot-separated paths without recursing forever", () => {
const idl: Idl = {
address: "Test111111111111111111111111111111111111111",
metadata: { name: "test", version: "0.0.0", spec: "0.1.0" },
instructions: [
{
name: "do_thing",
discriminator: [0, 0, 0, 0, 0, 0, 0, 0],
args: [],
accounts: [
{
name: "my_pda",
pda: {
seeds: [{ kind: "account", path: "my_account.field" } as any],
},
relations: ["other_account", "another_one"],
} as any,
],
},
],
};

const out = convertIdlToCamelCase(idl);

expect(out.instructions[0].name).toBe("doThing");
const acct = out.instructions[0].accounts[0] as any;
expect(acct.name).toBe("myPda");
// The split-on-`.` wrapper must camelCase each segment in isolation.
expect(acct.pda.seeds[0].path).toBe("myAccount.field");
expect(acct.relations).toEqual(["otherAccount", "anotherOne"]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-binary-option/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-binary-oracle-pair/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-feature-proposal/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-governance/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-memo/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-name-service/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-record/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-stake-pool/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-stateless-asks/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-token-lending/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-token-swap/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
1 change: 0 additions & 1 deletion ts/packages/spl-token/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
"bn.js",
"bs58",
"buffer",
"camelcase",
"eventemitter3",
"@noble/hashes/sha256",
"pako",
Expand Down
2 changes: 1 addition & 1 deletion ts/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1673,7 +1673,7 @@ camelcase@^5.0.0, camelcase@^5.3.1:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==

camelcase@^6.2.0, camelcase@^6.3.0:
camelcase@^6.2.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
Expand Down
Loading