Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/add-base-account-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"thirdweb": minor
---

Add Base Account SDK integration with `@base-org/account`

- Add @base-org/account 2.5.0 dependency
- Introduce Base Account SDK wallet connector
- Add base-account-web.ts with EIP-1193 provider implementation
- Add base-account-wallet.ts with core wallet logic
- Add types and helper function exports
1 change: 1 addition & 0 deletions packages/thirdweb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"url": "https://github.com/thirdweb-dev/js/issues"
},
"dependencies": {
"@base-org/account": "2.5.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Query npm registry for `@base-org/account` version 2.5.0
curl -s https://registry.npmjs.org/@base-org/account | jq '.versions["2.5.0"] | {version, dist: .dist | {tarball, shasum}}'

Repository: thirdweb-dev/js

Length of output: 240


🏁 Script executed:

# Also check the latest version available
curl -s https://registry.npmjs.org/@base-org/account | jq '.["dist-tags"]'

Repository: thirdweb-dev/js

Length of output: 204


🏁 Script executed:

# Check the current package.json to understand size-limit configuration
head -50 packages/thirdweb/package.json

Repository: thirdweb-dev/js

Length of output: 1487


🏁 Script executed:

# Search for size-limit configuration in package.json
grep -A 20 "size-limit" packages/thirdweb/package.json

Repository: thirdweb-dev/js

Length of output: 2457


🏁 Script executed:

# Also check if the change included updates to size-limit
git diff HEAD -- packages/thirdweb/package.json | head -100

Repository: thirdweb-dev/js

Length of output: 41


🏁 Script executed:

# Search for size-limit configuration array in the entire package.json
cat packages/thirdweb/package.json | jq '.["size-limit"]' 2>/dev/null || echo "No size-limit array found in package.json"

Repository: thirdweb-dev/js

Length of output: 63


🏁 Script executed:

# Also check the end of the file to see if size-limit config is there
tail -100 packages/thirdweb/package.json

Repository: thirdweb-dev/js

Length of output: 3731


Add size-limit configuration for the new @base-org/account dependency.

The @base-org/account package at version 2.5.0 is valid and follows the existing exact-version pinning pattern. However, per the coding guidelines, bundle budgets must be tracked via package.json#size-limit. The current package.json lacks a size-limit configuration array—add an entry for this dependency to track its bundle size impact. Additionally, consider updating to version 2.5.1 (the latest patch), as it's more recent than 2.5.0.

🤖 Prompt for AI Agents
In `@packages/thirdweb/package.json` at line 14, Update package.json to include a
size-limit configuration array and add an entry tracking the new
`@base-org/account` dependency (identify the package by its name
"@base-org/account" and include a descriptive label in the size-limit entry),
and change the dependency version to the latest patch (2.5.1) instead of 2.5.0;
ensure the size-limit array follows the existing project format and includes the
package path or import target used by the build so bundle budgets are enforced
for `@base-org/account`.

"@coinbase/wallet-sdk": "4.3.0",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
Expand Down
5 changes: 5 additions & 0 deletions packages/thirdweb/src/exports/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export type {
InjectedSupportedWalletIds,
WCSupportedWalletIds,
} from "../wallets/__generated__/wallet-ids.js";
export type {
BaseAccountSDKWalletConnectionOptions,
BaseAccountWalletCreationOptions,
} from "../wallets/base-account/base-account-web.js";
export { isBaseAccountSDKWallet } from "../wallets/base-account/base-account-web.js";
export type {
CoinbaseSDKWalletConnectionOptions,
CoinbaseWalletCreationOptions,
Expand Down
116 changes: 116 additions & 0 deletions packages/thirdweb/src/wallets/base-account/base-account-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* internal helper functions for Base Account SDK wallet
*/

import type { ProviderInterface } from "@base-org/account";
import { trackConnect } from "../../analytics/track/connect.js";
import type { Chain } from "../../chains/types.js";
import { getCachedChainIfExists } from "../../chains/utils.js";
import { BASE_ACCOUNT } from "../constants.js";
import type { Account, Wallet } from "../interfaces/wallet.js";
import { createWalletEmitter } from "../wallet-emitter.js";
import type { CreateWalletArgs } from "../wallet-types.js";

/**
* @internal
*/
export function baseAccountWalletSDK(args: {
createOptions?: CreateWalletArgs<typeof BASE_ACCOUNT>[1];
providerFactory: () => Promise<ProviderInterface>;
}): Wallet<typeof BASE_ACCOUNT> {
const { createOptions } = args;
const emitter = createWalletEmitter<typeof BASE_ACCOUNT>();
let account: Account | undefined;
let chain: Chain | undefined;

function reset() {
account = undefined;
chain = undefined;
}

let handleDisconnect = async () => {};

let handleSwitchChain = async (newChain: Chain) => {
chain = newChain;
};

const unsubscribeChainChanged = emitter.subscribe(
"chainChanged",
(newChain) => {
chain = newChain;
},
);

const unsubscribeDisconnect = emitter.subscribe("disconnect", () => {
reset();
unsubscribeChainChanged();
unsubscribeDisconnect();
});

emitter.subscribe("accountChanged", (_account) => {
account = _account;
});
Comment on lines +44 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential subscription leak for accountChanged event.

The chainChanged and disconnect subscriptions are properly cleaned up on disconnect (lines 46-47), but the accountChanged subscription (lines 50-52) is never unsubscribed. This could lead to memory leaks if the wallet is repeatedly connected and disconnected.

Proposed fix
+  const unsubscribeAccountChanged = emitter.subscribe("accountChanged", (_account) => {
+    account = _account;
+  });
+
   const unsubscribeDisconnect = emitter.subscribe("disconnect", () => {
     reset();
     unsubscribeChainChanged();
     unsubscribeDisconnect();
+    unsubscribeAccountChanged();
   });

-  emitter.subscribe("accountChanged", (_account) => {
-    account = _account;
-  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const unsubscribeDisconnect = emitter.subscribe("disconnect", () => {
reset();
unsubscribeChainChanged();
unsubscribeDisconnect();
});
emitter.subscribe("accountChanged", (_account) => {
account = _account;
});
const unsubscribeAccountChanged = emitter.subscribe("accountChanged", (_account) => {
account = _account;
});
const unsubscribeDisconnect = emitter.subscribe("disconnect", () => {
reset();
unsubscribeChainChanged();
unsubscribeDisconnect();
unsubscribeAccountChanged();
});
🤖 Prompt for AI Agents
In `@packages/thirdweb/src/wallets/base-account/base-account-wallet.ts` around
lines 44 - 52, The accountChanged subscription is never cleaned up which can
leak on repeated connects; capture its unsubscribe function (e.g., const
unsubscribeAccountChanged = emitter.subscribe("accountChanged", ...)) and call
unsubscribeAccountChanged() inside the disconnect handler alongside
unsubscribeChainChanged() and unsubscribeDisconnect() (and any other teardown
paths) so all three subscriptions are properly unsubscribed when the wallet
disconnects; update references in base-account-wallet.ts to use
unsubscribeAccountChanged where accountChanged is subscribed.


return {
autoConnect: async (options) => {
const { autoConnectBaseAccountSDK } = await import(
"./base-account-web.js"
);
const provider = await args.providerFactory();
const [connectedAccount, connectedChain, doDisconnect, doSwitchChain] =
await autoConnectBaseAccountSDK(options, emitter, provider);
// set the states
account = connectedAccount;
chain = connectedChain;
handleDisconnect = doDisconnect;
handleSwitchChain = doSwitchChain;
trackConnect({
chainId: chain.id,
client: options.client,
walletAddress: account.address,
walletType: BASE_ACCOUNT,
});
// return account
return account;
},
connect: async (options) => {
const { connectBaseAccountSDK } = await import("./base-account-web.js");
const provider = await args.providerFactory();
const [connectedAccount, connectedChain, doDisconnect, doSwitchChain] =
await connectBaseAccountSDK(options, emitter, provider);

// set the states
account = connectedAccount;
chain = connectedChain;
handleDisconnect = doDisconnect;
handleSwitchChain = doSwitchChain;
trackConnect({
chainId: chain.id,
client: options.client,
walletAddress: account.address,
walletType: BASE_ACCOUNT,
});
// return account
return account;
},
disconnect: async () => {
reset();
await handleDisconnect();
},
getAccount: () => account,
getChain() {
if (!chain) {
return undefined;
}

chain = getCachedChainIfExists(chain.id) || chain;
return chain;
},
getConfig: () => createOptions,
id: BASE_ACCOUNT,
subscribe: emitter.subscribe,
switchChain: async (newChain) => {
await handleSwitchChain(newChain);
},
};
}
177 changes: 177 additions & 0 deletions packages/thirdweb/src/wallets/base-account/base-account-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { ProviderInterface } from "@base-org/account";
import * as ox__Hex from "ox/Hex";
import * as ox__TypedData from "ox/TypedData";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { BASE_ACCOUNT } from "../constants.js";
import type { Wallet } from "../interfaces/wallet.js";
import {
autoConnectBaseAccountSDK,
connectBaseAccountSDK,
isBaseAccountSDKWallet,
} from "./base-account-web.js";

// Mock dependencies
vi.mock("@base-org/account", () => ({
createBaseAccountSDK: vi.fn(() => ({
getProvider: () => ({
disconnect: vi.fn(),
emit: vi.fn(),
off: vi.fn(),
on: vi.fn(),
request: vi.fn(),
}),
})),
}));

vi.mock("../../utils/address.js", () => ({
getAddress: vi.fn((address) => address),
}));

vi.mock("../../chains/utils.js", () => ({
getCachedChain: vi.fn((chainId) => ({ id: chainId })),
getChainMetadata: vi.fn(async (_chain) => ({
explorers: [{ url: "https://explorer.test" }],
name: "Test Chain",
nativeCurrency: { decimals: 18, name: "Test Coin", symbol: "TC" },
})),
}));

vi.mock("../utils/normalizeChainId.js", () => ({
normalizeChainId: vi.fn((chainId) => Number(chainId)),
}));

vi.mock("ox/Hex", async () => {
const actualModule = await vi.importActual("ox/Hex");
return {
...actualModule,
toNumber: vi.fn((hex) => Number.parseInt(hex, 16)),
validate: vi.fn(() => true),
};
});

vi.mock("ox/TypedData", () => ({
extractEip712DomainTypes: vi.fn(() => []),
serialize: vi.fn(() => "serializedData"),
validate: vi.fn(),
}));

describe("Base Account Web", () => {
let provider: ProviderInterface;

beforeEach(async () => {
// Reset module to clear cached provider
vi.resetModules();
const module = await import("./base-account-web.js");
provider = await module.getBaseAccountWebProvider();
});

test("getBaseAccountWebProvider initializes provider", async () => {
expect(provider).toBeDefined();
expect(provider.request).toBeInstanceOf(Function);
});

test("isBaseAccountSDKWallet returns true for Base Account wallet", () => {
const wallet: Wallet = { id: BASE_ACCOUNT } as Wallet;
expect(isBaseAccountSDKWallet(wallet)).toBe(true);
});

test("isBaseAccountSDKWallet returns false for non-Base Account wallet", () => {
const wallet: Wallet = { id: "other" } as unknown as Wallet;
expect(isBaseAccountSDKWallet(wallet)).toBe(false);
});

test("connectBaseAccountSDK connects to the wallet", async () => {
provider.request = vi
.fn()
.mockResolvedValueOnce(["0x123"])
.mockResolvedValueOnce("0x1");
const emitter = { emit: vi.fn() };
const options = { client: {} };

const [account, chain] = await connectBaseAccountSDK(
// biome-ignore lint/suspicious/noExplicitAny: Inside tests
options as any,
// biome-ignore lint/suspicious/noExplicitAny: Inside tests
emitter as any,
provider,
);

expect(account.address).toBe("0x123");
expect(chain.id).toBe(1);
});

test("autoConnectBaseAccountSDK auto-connects to the wallet", async () => {
provider.request = vi
.fn()
.mockResolvedValueOnce(["0x123"])
.mockResolvedValueOnce("0x1");
const emitter = { emit: vi.fn() };
const options = { client: {} };

const [account, chain] = await autoConnectBaseAccountSDK(
// biome-ignore lint/suspicious/noExplicitAny: Inside tests
options as any,
// biome-ignore lint/suspicious/noExplicitAny: Inside tests
emitter as any,
provider,
);

expect(account.address).toBe("0x123");
expect(chain.id).toBe(1);
Comment on lines +83 to +120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use shared test accounts instead of hardcoded addresses.

Tests currently use "0x123"; please switch to the predefined accounts from test/src/test-wallets.ts to keep fixtures consistent. As per coding guidelines, use the shared test accounts.

Also applies to: 123-176

🤖 Prompt for AI Agents
In `@packages/thirdweb/src/wallets/base-account/base-account-web.test.ts` around
lines 83 - 120, Replace hardcoded test addresses ("0x123") in the base-account
web tests with the shared test accounts exported from test/src/test-wallets.ts;
locate usages inside the connectBaseAccountSDK and autoConnectBaseAccountSDK
tests (and any other assertions in this file that check account.address) and
import the shared fixture (e.g., the test wallet constant) then assert against
that constant instead of the literal string so tests use the centralized
fixtures.

});

test("signMessage uses ox__Hex for validation", async () => {
const account = {
address: "0x123",
signMessage: async ({ message }: { message: string }) => {
const messageToSign = `0x${ox__Hex.fromString(message)}`;
const res = await provider.request({
method: "personal_sign",
params: [messageToSign, account.address],
});
expect(ox__Hex.validate(res)).toBe(true);
return res;
},
};

provider.request = vi.fn().mockResolvedValue("0xsignature");
const signature = await account.signMessage({ message: "hello" });
expect(signature).toBe("0xsignature");
});

test("signTypedData uses ox__TypedData for serialization", async () => {
const account = {
address: "0x123",
// biome-ignore lint/suspicious/noExplicitAny: Inside tests
signTypedData: async (typedData: any) => {
const { domain, message, primaryType } = typedData;
const types = {
EIP712Domain: ox__TypedData.extractEip712DomainTypes(domain),
...typedData.types,
};
ox__TypedData.validate({ domain, message, primaryType, types });
const stringifiedData = ox__TypedData.serialize({
domain: domain ?? {},
message,
primaryType,
types,
});
const res = await provider.request({
method: "eth_signTypedData_v4",
params: [account.address, stringifiedData],
});
expect(ox__Hex.validate(res)).toBe(true);
return res;
},
};

provider.request = vi.fn().mockResolvedValue("0xsignature");
const signature = await account.signTypedData({
domain: {},
message: {},
primaryType: "EIP712Domain",
types: {},
});
expect(signature).toBe("0xsignature");
});
});
Loading