Skip to content

Commit fca8689

Browse files
committed
token salt guard
1 parent 5287079 commit fca8689

File tree

4 files changed

+139
-16
lines changed

4 files changed

+139
-16
lines changed

packages/thirdweb/src/exports/tokens.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export { createToken } from "../tokens/create-token.js";
1313
export { distributeToken } from "../tokens/distribute-token.js";
1414
export { getDeployedEntrypointERC20 } from "../tokens/get-entrypoint-erc20.js";
1515
export { isRouterEnabled } from "../tokens/is-router-enabled.js";
16+
export {
17+
generateSalt,
18+
SaltFlag,
19+
type SaltFlagType,
20+
} from "../tokens/token-utils.js";
1621
export type {
1722
CreateTokenByImplementationConfigOptions,
1823
CreateTokenOptions,

packages/thirdweb/src/tokens/create-token.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import { bytesToHex, randomBytes } from "@noble/hashes/utils";
12
import type { Hex } from "viem";
23
import { parseEventLogs } from "../event/actions/parse-logs.js";
34
import { createdEvent } from "../extensions/tokens/__generated__/ERC20Entrypoint/events/Created.js";
45
import { create } from "../extensions/tokens/__generated__/ERC20Entrypoint/write/create.js";
56
import { sendAndConfirmTransaction } from "../transaction/actions/send-and-confirm-transaction.js";
6-
import { keccakId } from "../utils/any-evm/keccak-id.js";
7-
import { toHex } from "../utils/encoding/hex.js";
87
import { DEFAULT_REFERRER_ADDRESS } from "./constants.js";
98
import { getOrDeployEntrypointERC20 } from "./get-entrypoint-erc20.js";
10-
import { encodeInitParams, encodePoolConfig } from "./token-utils.js";
9+
import {
10+
encodeInitParams,
11+
encodePoolConfig,
12+
generateSalt,
13+
} from "./token-utils.js";
1114
import type { CreateTokenOptions } from "./types.js";
1215

1316
export async function createToken(options: CreateTokenOptions) {
@@ -20,18 +23,7 @@ export async function createToken(options: CreateTokenOptions) {
2023
params,
2124
});
2225

23-
let salt: Hex = "0x";
24-
if (!options.salt) {
25-
salt = `0x1f${toHex(creator, {
26-
size: 32,
27-
}).substring(4)}`;
28-
} else {
29-
if (options.salt.startsWith("0x") && options.salt.length === 66) {
30-
salt = options.salt;
31-
} else {
32-
salt = `0x1f${keccakId(options.salt).substring(4)}`;
33-
}
34-
}
26+
const salt: Hex = generateSalt(options.salt || bytesToHex(randomBytes(31)));
3527

3628
const entrypoint = await getOrDeployEntrypointERC20(options);
3729

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { isHex } from "viem/utils";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { generateSalt, SaltFlag } from "./token-utils.js";
5+
6+
// Helper to validate the generated salt meets core guarantees
7+
function assertValidSalt(out: string, expectedFlagHex: string) {
8+
// should be valid hex
9+
console.log("out", out);
10+
expect(isHex(out)).toBe(true);
11+
// 0x prefix + 64 hex chars → 32 bytes
12+
expect(out.length).toBe(66);
13+
// first byte must match the expected flag
14+
expect(out.slice(0, 4).toLowerCase()).toBe(expectedFlagHex.toLowerCase());
15+
}
16+
17+
describe("generateSalt", () => {
18+
it("handles hex shorter than 32 bytes (padding)", () => {
19+
const shortHex = "0x123456"; // 3 bytes < 32 bytes
20+
const out = generateSalt(shortHex);
21+
assertValidSalt(out, "0x01"); // default flag is MIX_SENDER (0x01)
22+
});
23+
24+
it("handles exactly 32-byte hex and overrides explicit flag with salt's flag", () => {
25+
const hex32 = `0x20${"aa".repeat(31)}`; // first byte 0x20, total 32 bytes
26+
const out = generateSalt(hex32, SaltFlag.BYPASS); // pass a different flag intentionally
27+
// even though we requested BYPASS (0x80), the salt's first byte (0x20) wins
28+
assertValidSalt(out, "0x20");
29+
});
30+
31+
it("handles hex longer than 32 bytes (hashing)", () => {
32+
const longHex = `0x${"ff".repeat(80)}`; // 80 bytes > 32 bytes
33+
const out = generateSalt(longHex);
34+
assertValidSalt(out, "0x01"); // default flag retained
35+
});
36+
37+
it("handles arbitrary non-hex string (hashed)", () => {
38+
const out = generateSalt("hello world");
39+
assertValidSalt(out, "0x01");
40+
});
41+
42+
it("respects explicit flag parameter when provided", () => {
43+
const out = generateSalt("foobar", SaltFlag.BYPASS);
44+
assertValidSalt(out, "0x80");
45+
});
46+
});

packages/thirdweb/src/tokens/token-utils.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Hex } from "viem";
1+
import { type Hex, keccak256 } from "viem";
2+
import { isHex, toBytes } from "viem/utils";
23
import type { ThirdwebClient } from "../client/client.js";
34
import { NATIVE_TOKEN_ADDRESS } from "../constants/addresses.js";
45
import { encodeInitialize } from "../extensions/tokens/__generated__/ERC20Asset/write/initialize.js";
@@ -12,6 +13,25 @@ import {
1213
} from "./constants.js";
1314
import type { PoolConfig, TokenParams } from "./types.js";
1415

16+
export const SaltFlag = {
17+
/** Mix in msg.sender */
18+
MIX_SENDER: 0x01,
19+
/** Mix in block.chainid */
20+
MIX_CHAIN_ID: 0x02,
21+
/** Mix in block.number */
22+
MIX_BLOCK_NUMBER: 0x04,
23+
/** Mix in contractInitData */
24+
MIX_CONTRACT_INIT_DATA: 0x08,
25+
/** Mix in hookInitData */
26+
MIX_HOOK_INIT_DATA: 0x10,
27+
/** Mix in creator address */
28+
MIX_CREATOR: 0x20,
29+
/** Bypass mode – disable all transformations */
30+
BYPASS: 0x80,
31+
} as const;
32+
33+
export type SaltFlagType = (typeof SaltFlag)[keyof typeof SaltFlag];
34+
1535
export async function encodeInitParams(options: {
1636
client: ThirdwebClient;
1737
params: TokenParams;
@@ -75,3 +95,63 @@ export function encodePoolConfig(poolConfig: PoolConfig): Hex {
7595
poolConfig.referrerRewardBps || DEFAULT_REFERRER_REWARD_BPS,
7696
]);
7797
}
98+
99+
export function generateSalt(
100+
salt: Hex | string,
101+
flags: SaltFlagType = SaltFlag.MIX_SENDER,
102+
): Hex {
103+
/*
104+
* The salt layout follows the on-chain convention documented in the `guardSalt` Solidity helper.
105+
* [0x00] – 1 byte : flags (bits 0-7)
106+
* [0x01-0x1F] – 31 bytes : user-provided entropy
107+
*
108+
* This helper makes it easy to prepare a salt off-chain that can be passed to
109+
* the contract. It guarantees the returned value is always 32 bytes (66 hex
110+
* chars including the `0x` prefix) and allows callers to optionally pass a
111+
* custom salt and/or explicit flag byte.
112+
*/
113+
114+
let flagByte: number = flags;
115+
116+
// If the salt is already a valid 32-byte hex string, we can use it as is and extract the flag byte
117+
if (salt && isHex(salt)) {
118+
const hex = salt.replace(/^0x/i, "");
119+
if (hex.length === 64) {
120+
flagByte = parseInt(hex.slice(0, 2), 16) as SaltFlagType;
121+
salt = `0x${hex.slice(2)}` as Hex;
122+
} else if (hex.length < 64) {
123+
// If the salt is less than 31 bytes, we need to pad it with zeros (first 2 bytes are the flag byte)
124+
salt = `0x${hex.padStart(62, "0")}` as Hex;
125+
} else if (hex.length > 64) {
126+
// If the salt is greater than 32 bytes, we need to keccak256 it.
127+
// first 2 bytes are the flag byte, and truncate to 31 bytes
128+
salt = `0x${keccak256(toBytes(hex)).slice(4)}` as Hex;
129+
}
130+
} else if (salt && !isHex(salt)) {
131+
// 31 bytes of salt data
132+
salt = `0x${keccak256(toBytes(salt)).slice(4)}` as Hex;
133+
}
134+
135+
// If the flag byte is not a valid 8-bit unsigned integer, throw an error
136+
if (flagByte < 0 || flagByte > 0xff) {
137+
throw new Error("flags must be an 8-bit unsigned integer (0-255)");
138+
}
139+
140+
const saltData = salt.replace(/^0x/i, "");
141+
if (saltData.length !== 62) {
142+
// 31 bytes * 2 hex chars
143+
throw new Error(
144+
"salt data (excluding flag byte) cannot exceed 31 bytes (62 hex characters)",
145+
);
146+
}
147+
148+
const result =
149+
`0x${flagByte.toString(16).padStart(2, "0")}${saltData}` as Hex;
150+
151+
// Final sanity check – should always be 32 bytes / 66 hex chars (including 0x)
152+
if (result.length !== 66) {
153+
throw new Error("generated salt must be 32 bytes");
154+
}
155+
156+
return result;
157+
}

0 commit comments

Comments
 (0)