Skip to content

Commit f0988d1

Browse files
chore: sync from internal
1 parent 31dd6bd commit f0988d1

28 files changed

Lines changed: 776 additions & 82 deletions

.github/CODEOWNERS

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
# Security can use overrides to approve when needed
1+
/.github/workflows/*.yml @phantom/security
22

3+
# Security can use overrides to approve when needed rather than CO
34
**/yarn.lock @phantom/wallet-platform
45
**/package.json @phantom/wallet-platform

packages/auth2/src/Auth2Stamper.ts

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class Auth2Stamper implements Auth2StamperWithKeyManagement {
2525
private _refreshToken: string | null = null;
2626
private _tokenExpiresAt: number | null = null;
2727

28-
private isRefreshingTokens: boolean = false;
28+
private refreshingTokensPromise: Promise<boolean> | null = null;
2929

3030
// Because TOKEN_REFRESH_BUFFER_MS proactively handles refresh, we can safely use void here
3131
// and return the existing cached values immediately.
@@ -124,43 +124,51 @@ export class Auth2Stamper implements Auth2StamperWithKeyManagement {
124124

125125
/**
126126
* Checks if tokens should be refreshed and performs a refresh if needed.
127-
* Returns void after attempting any necessary refresh.
127+
* Returns true if a refresh succeeded, false otherwise.
128128
*/
129-
private async maybeRefreshTokens(): Promise<void> {
129+
async maybeRefreshTokens(): Promise<boolean> {
130+
if (this.refreshingTokensPromise) {
131+
return this.refreshingTokensPromise;
132+
}
133+
130134
if (
131-
this.isRefreshingTokens ||
132135
!this.refreshConfig ||
133136
!this._refreshToken ||
134137
!this._tokenExpiresAt ||
135-
// Skip refresh if the token still has plenty of time left.
136138
Date.now() < this._tokenExpiresAt - TOKEN_REFRESH_BUFFER_MS
137139
) {
138-
return;
140+
return false;
139141
}
140142

141-
this.isRefreshingTokens = true;
142-
143-
try {
144-
const refreshed = await refreshTokenRequest({
145-
authApiBaseUrl: this.refreshConfig.authApiBaseUrl,
146-
clientId: this.refreshConfig.clientId,
147-
redirectUri: this.refreshConfig.redirectUri,
148-
refreshToken: this._refreshToken,
149-
});
143+
const refreshConfig = this.refreshConfig;
144+
const refreshToken = this._refreshToken;
145+
146+
this.refreshingTokensPromise = (async () => {
147+
try {
148+
const refreshed = await refreshTokenRequest({
149+
authApiBaseUrl: refreshConfig.authApiBaseUrl,
150+
clientId: refreshConfig.clientId,
151+
redirectUri: refreshConfig.redirectUri,
152+
refreshToken,
153+
});
154+
155+
await this.setTokens({
156+
accessToken: refreshed.accessToken,
157+
idType: refreshed.idType,
158+
refreshToken: refreshed.refreshToken,
159+
expiresInMs: refreshed.expiresInMs,
160+
});
161+
return true;
162+
} catch (error) {
163+
console.error("Failed to refresh tokens:", error);
164+
return false;
165+
} finally {
166+
this.refreshingTokensPromise = null;
167+
}
168+
})();
150169

151-
await this.setTokens({
152-
accessToken: refreshed.accessToken,
153-
idType: refreshed.idType,
154-
refreshToken: refreshed.refreshToken,
155-
expiresInMs: refreshed.expiresInMs,
156-
});
157-
} catch (error) {
158-
console.error("Failed to refresh tokens:", error);
159-
} finally {
160-
this.isRefreshingTokens = false;
161-
}
170+
return this.refreshingTokensPromise;
162171
}
163-
164172
async stamp(params: { data: Buffer; type?: "PKI" } | { data: Buffer; type: "OIDC" }): Promise<string> {
165173
if (!this._keyPair || !this._keyInfo || !this.auth2Token) {
166174
throw new Error("Auth2Stamper not initialized. Call init() first.");

packages/auth2/src/__tests__/Auth2Stamper.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,143 @@ describe("Auth2Stamper", () => {
455455
});
456456
});
457457

458+
describe("maybeRefreshTokens()", () => {
459+
it("returns true and updates bearerToken after a successful refresh", async () => {
460+
const mockFetch = globalThis.fetch as jest.Mock;
461+
mockFetch.mockResolvedValueOnce({
462+
ok: true,
463+
json: () =>
464+
Promise.resolve({
465+
access_token: "forced-new-access",
466+
refresh_token: "forced-new-refresh",
467+
token_type: "Bearer",
468+
expires_in: 3600,
469+
}),
470+
});
471+
472+
const storage = makeStorage(STORED_RECORD);
473+
const stamper = new Auth2Stamper(storage, {
474+
authApiBaseUrl: "https://auth.example.com",
475+
clientId: "client-1",
476+
redirectUri: "https://app.example.com/callback",
477+
});
478+
await stamper.init();
479+
(stamper as any)._tokenExpiresAt = Date.now() - 1000;
480+
481+
const result = await stamper.maybeRefreshTokens();
482+
483+
expect(result).toBe(true);
484+
expect(stamper.bearerToken).toBe("Bearer forced-new-access");
485+
});
486+
487+
it("returns false when no refreshToken is available", async () => {
488+
const storage = makeStorage({ ...STORED_RECORD, refreshToken: undefined });
489+
const stamper = new Auth2Stamper(storage, {
490+
authApiBaseUrl: "https://auth.example.com",
491+
clientId: "client-1",
492+
redirectUri: "https://app.example.com/callback",
493+
});
494+
await stamper.init();
495+
(stamper as any)._refreshToken = null;
496+
497+
const result = await stamper.maybeRefreshTokens();
498+
499+
expect(result).toBe(false);
500+
});
501+
502+
it("returns false when no refreshConfig is provided", async () => {
503+
const storage = makeStorage(STORED_RECORD);
504+
const stamper = new Auth2Stamper(storage); // no refreshConfig
505+
await stamper.init();
506+
507+
const result = await stamper.maybeRefreshTokens();
508+
509+
expect(result).toBe(false);
510+
});
511+
512+
it("returns false and does not throw when the refresh request fails", async () => {
513+
const mockFetch = globalThis.fetch as jest.Mock;
514+
mockFetch.mockRejectedValueOnce(new Error("network error"));
515+
516+
const storage = makeStorage(STORED_RECORD);
517+
const stamper = new Auth2Stamper(storage, {
518+
authApiBaseUrl: "https://auth.example.com",
519+
clientId: "client-1",
520+
redirectUri: "https://app.example.com/callback",
521+
});
522+
await stamper.init();
523+
(stamper as any)._refreshToken = "stored-refresh-token";
524+
(stamper as any)._tokenExpiresAt = Date.now() - 1000;
525+
526+
const result = await stamper.maybeRefreshTokens();
527+
528+
expect(result).toBe(false);
529+
// bearerToken unchanged — still the stored access token
530+
expect(stamper.bearerToken).toBe("Bearer stored-access-token");
531+
});
532+
533+
it("shares the in-flight refresh result and does not make a second request when already refreshing", async () => {
534+
const mockFetch = globalThis.fetch as jest.Mock;
535+
let resolveRefresh!: () => void;
536+
mockFetch.mockReturnValueOnce(
537+
new Promise(resolve => {
538+
resolveRefresh = () =>
539+
resolve({
540+
ok: true,
541+
json: () =>
542+
Promise.resolve({
543+
access_token: "new",
544+
refresh_token: "new-r",
545+
token_type: "Bearer",
546+
expires_in: 3600,
547+
}),
548+
});
549+
}),
550+
);
551+
552+
const storage = makeStorage(STORED_RECORD);
553+
const stamper = new Auth2Stamper(storage, {
554+
authApiBaseUrl: "https://auth.example.com",
555+
clientId: "c",
556+
redirectUri: "https://app.example.com/cb",
557+
});
558+
await stamper.init();
559+
(stamper as any)._refreshToken = "stored-refresh-token";
560+
(stamper as any)._tokenExpiresAt = Date.now() - 1000;
561+
562+
// Start a refresh but don't await it yet
563+
const firstRefresh = stamper.maybeRefreshTokens();
564+
// Second call while first is in flight
565+
const secondRefresh = stamper.maybeRefreshTokens();
566+
567+
resolveRefresh();
568+
const [firstResult, secondResult] = await Promise.all([firstRefresh, secondRefresh]);
569+
expect(firstResult).toBe(true);
570+
expect(secondResult).toBe(true);
571+
expect(mockFetch).toHaveBeenCalledTimes(1);
572+
});
573+
574+
it("returns false when the token is not near expiry", async () => {
575+
const mockFetch = globalThis.fetch as jest.Mock;
576+
577+
const storage = makeStorage(STORED_RECORD);
578+
const stamper = new Auth2Stamper(storage, {
579+
authApiBaseUrl: "https://auth.example.com",
580+
clientId: "c",
581+
redirectUri: "https://app.example.com/cb",
582+
});
583+
await stamper.init();
584+
585+
(stamper as any)._tokenExpiresAt = Date.now() + 3_600_000;
586+
(stamper as any)._refreshToken = "stored-refresh-token";
587+
588+
const result = await stamper.maybeRefreshTokens();
589+
590+
expect(result).toBe(false);
591+
expect(mockFetch).not.toHaveBeenCalled();
592+
});
593+
});
594+
458595
describe("token refresh", () => {
459596
it("triggers a background refresh when the token is near expiry", async () => {
460597
const mockFetch = globalThis.fetch as jest.Mock;

packages/auth2/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface Auth2StamperWithKeyManagement extends StamperWithKeyManagement
55
auth2Token: Auth2Token | null;
66
bearerToken: string | null;
77
getCryptoKeyPair(): CryptoKeyPair | null;
8+
maybeRefreshTokens(): Promise<boolean>;
89
setTokens(options: {
910
accessToken: string;
1011
idType: string;

packages/mcp-server/README.md

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
# @phantom/mcp-server
22

3-
> **⚠️ PREVIEW DISCLAIMER**
4-
>
5-
> This MCP server is currently in **preview** and may break or change at any time without notice.
6-
>
7-
> **Always use a separate Phantom account specifically for testing with AI agents. These accounts should not contain significant assets.**
8-
>
9-
> **Phantom makes no guarantees whatsoever around anything your agent may do using this MCP server.** Use at your own risk.
10-
113
An MCP (Model Context Protocol) server that provides LLMs like Claude with direct access to Phantom wallet operations. This enables AI assistants to interact with embedded wallets, view addresses, simulate transactions, check token approvals, sign and send transactions, and sign messages across Solana and EVM chains through natural language interactions.
124

135
## Features
@@ -52,15 +44,15 @@ An MCP (Model Context Protocol) server that provides LLMs like Claude with direc
5244
Use npx to run the server without global installation. This ensures you always use the latest version:
5345

5446
```bash
55-
npx -y @phantom/mcp-server
47+
npx -y @phantom/mcp-server@latest
5648
```
5749

5850
### Option 2: Global Install
5951

6052
Install the package globally for faster startup:
6153

6254
```bash
63-
npm install -g @phantom/mcp-server
55+
npm install -g @phantom/mcp-server@latest
6456
```
6557

6658
Then run:
@@ -87,7 +79,7 @@ Add the MCP server to your Claude Desktop configuration file:
8779
"mcpServers": {
8880
"phantom": {
8981
"command": "npx",
90-
"args": ["-y", "@phantom/mcp-server"]
82+
"args": ["-y", "@phantom/mcp-server@latest"]
9183
}
9284
}
9385
}
@@ -153,7 +145,7 @@ In current versions, agents receive a new wallet when they authenticate. That me
153145
Test the server directly using the MCP inspector:
154146

155147
```bash
156-
npx @modelcontextprotocol/inspector npx -y @phantom/mcp-server
148+
npx @modelcontextprotocol/inspector npx -y @phantom/mcp-server@latest
157149
```
158150

159151
This opens an interactive web UI where you can test tool calls without Claude Desktop.
@@ -196,11 +188,6 @@ All EVM tools (`send_evm_transaction`, `sign_evm_personal_message`, `sign_evm_ty
196188

197189
## Available Tools
198190

199-
> **Execution Warning**
200-
> `send_solana_transaction`, `send_evm_transaction`, `transfer_tokens`, `buy_token` (when `execute: true`), `deposit_to_hyperliquid`, `open_perp_position`, `close_perp_position`, `cancel_perp_order`, `update_perp_leverage`, `transfer_spot_to_perps`, and `withdraw_from_perps` all submit transactions immediately and irreversibly. Always verify parameters before calling these tools.
201-
202-
---
203-
204191
### 1. get_connection_status
205192

206193
Lightweight check of the current Phantom wallet connection state. Does not make any network or API calls — reads local session state only. Use this to confirm the user is authenticated before other operations.

packages/mcp-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@phantom/mcp-server",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"description": "MCP Server for Phantom Wallet",
55
"repository": {
66
"type": "git",

0 commit comments

Comments
 (0)