Skip to content
Merged
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
37 changes: 28 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,19 +435,25 @@ helius.zk.getValidityProof({ hashes }) // Validity proof

### Auth

The auth namespace is available on the main `HeliusClient` as `helius.auth.*`. It is used for programmatic agent signup flows. The step-by-step flow requires signing an auth message first to obtain a JWT, then using that JWT for all subsequent API calls.
The auth namespace is available on the main `HeliusClient` as `helius.auth.*`. It is used for programmatic agent signup flows. New accounts are created only through `signup` / `signupAndPay`, which require `email`, `firstName`, and `lastName`; the wallet is used to authenticate and pay. `createProject` and `payUSDC` are deprecated — the backend no longer creates projects for wallet-only sign-ins.

```typescript
// Step-by-step flow (JWT-based):
const keypair = await helius.auth.generateKeypair(); // Generate Ed25519 keypair
const address = await helius.auth.getAddress(keypair); // Get wallet address (async)
const { message, signature } = await helius.auth.signAuthMessage(keypair.secretKey); // Sign auth message
const { token } = await helius.auth.walletSignup(message, signature, address); // Get JWT via signup
const projects = await helius.auth.listProjects(token); // List projects (needs JWT)
const project = await helius.auth.createProject(token); // Create project (needs JWT)
const apiKey = await helius.auth.createApiKey(token, project.id, address); // Create API key (needs JWT)

// Or use the all-in-one shortcut (signs the auth message, creates a hosted
// Hosted checkout. Contact info is required when the wallet has no account yet.
const signupResult = await helius.auth.signup({
secretKey: keypair.secretKey,
plan: "agent",
email: "you@example.com",
firstName: "Jane",
lastName: "Doe",
});
if (signupResult.kind === "payment_required") {
// Open in a browser and pay with any wallet.
console.log(signupResult.paymentLink.paymentUrl);
}

// Or the all-in-one shortcut (signs the auth message, creates a hosted
// checkout, auto-pays USDC + memo from the local keypair, polls activation):
const result = await helius.auth.signupAndPay({
secretKey: keypair.secretKey,
Expand All @@ -464,6 +470,19 @@ const result = await helius.auth.signupAndPay({
// "expired" / "failed" — payment never completed; inspect `paymentIntentId` (and `reason` for failed)
```

Recovering the API key for a wallet that already has an account (no contact info needed):

```typescript
const address = await helius.auth.getAddress(keypair); // Wallet address
const { message, signature } = await helius.auth.signAuthMessage(keypair.secretKey); // Sign auth message
const { token } = await helius.auth.walletSignup(message, signature, address); // Get JWT
const [project] = await helius.auth.listProjects(token); // Existing project
const details = await helius.auth.getProject(token, project.id); // Includes apiKeys
const apiKey =
details.apiKeys?.[0]?.keyId ??
(await helius.auth.createApiKey(token, project.id, address)).keyId; // Create one if none
```

## Documentation

- Full API Reference: https://docs.helius.dev
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

## Unreleased

### Changed

- **Signup validates email format** — when creating a new account, `signup` and `signupAndPay` now reject a malformed `email` before creating a payment intent, in addition to the existing `email` / `firstName` / `lastName` presence check. Wallets that already have a project are unaffected.
- **`payUSDC` no longer sends funds** — it now throws. The backend no longer creates projects for wallet-only sign-ins, so the 1 USDC unlock fee would buy nothing.

### Deprecated

- **`createProject`, `payUSDC`, and `PAYMENT_AMOUNT`** — the legacy 1 USDC wallet-only project-creation flow. The backend no longer creates projects for wallet-only sign-ins; use `signup` / `signupAndPay`. These will be removed in the next major version.

## [v3.2.0](https://github.com/helius-labs/helius-sdk/compare/v3.1.0...v3.2.0) - 2026-09-08

Version 1 transactions made execution-ready for the Agave 4.2 mainnet activation, a transport augmentation hook, and correct on-chain-failure reporting from confirmation polling.
Expand Down
24 changes: 24 additions & 0 deletions bundlemon.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@ export default {
path: 'dist/esm/auth/client.js',
maxSize: '2.5kb',
},
{
// Opt out of the default 15% growth ratchet: the deprecation JSDoc on
// createProject / payUSDC (wallet-created projects were removed) is kept
// in the output, and payUSDC now throws instead of sending USDC. These
// modules are tiny, so a few comment lines exceed 15%.
path: 'dist/esm/auth/createProject.js',
maxSize: '1kb',
},
{
path: 'dist/esm/auth/payUSDC.js',
maxSize: '1kb',
},
{
// Opt out of the default 15% growth ratchet: adds normalizeContactField,
// requireContactInfo and EMAIL_PATTERN, shared by signup and upgradePlan.
path: 'dist/esm/auth/signupHelpers.js',
maxSize: '1kb',
},
{
// Opt out of the default 15% growth ratchet: upgradePlan now validates
// contact info (shared requireContactInfo) when any field is passed.
path: 'dist/esm/auth/upgradePlan.js',
maxSize: '1.5kb',
},
{
path: 'dist/esm/auth/constants.js',
maxSize: '1.5kb',
Expand Down
6 changes: 5 additions & 1 deletion src/auth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export const USDC_MINT_DEVNET =

export const USDC_MINT = USDC_MINT_MAINNET;

/** Legacy: 1 USDC (6 decimals). Only used by payUSDC. */
/**
* Legacy: 1 USDC (6 decimals). Only used by payUSDC.
*
* @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`).
*/
export const PAYMENT_AMOUNT = 1_000_000n;

/**
Expand Down
3 changes: 3 additions & 0 deletions src/auth/createProject.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Project } from "./types";
import { authRequest } from "./utils";

/**
* @deprecated Wallet-created projects are no longer supported by the backend. Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`).
*/
export async function createProject(
jwt: string,
userAgent?: string
Expand Down
22 changes: 12 additions & 10 deletions src/auth/payUSDC.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { TREASURY, USDC_MINT, PAYMENT_AMOUNT } from "./constants";
import { buildAndSendTokenTransfer } from "./buildTokenTransfer";

export async function payUSDC(secretKey: Uint8Array): Promise<string> {
return buildAndSendTokenTransfer({
secretKey,
recipientAddress: TREASURY,
mintAddress: USDC_MINT,
amount: PAYMENT_AMOUNT,
});
/**
* Legacy 1 USDC unlock-fee transfer for wallet-created projects.
*
* Always throws without sending funds: the backend no longer creates projects
* for wallet-only sign-ins, so the fee would buy nothing.
*
* @deprecated Use `signup` / `signupAndPay` (requires `email`, `firstName`, `lastName`).
*/
export async function payUSDC(_secretKey: Uint8Array): Promise<string> {
throw new Error(
"payUSDC is no longer supported: wallet-created projects were removed. Use signup or signupAndPay with email, firstName and lastName."
);
}
19 changes: 5 additions & 14 deletions src/auth/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { walletSignup } from "./walletSignup";
import { listProjects } from "./listProjects";
import { getProject } from "./getProject";
import { createApiKey } from "./createApiKey";
import { buildEndpoints } from "./signupHelpers";
import { buildEndpoints, requireContactInfo } from "./signupHelpers";
import { createPayment } from "./createPayment";
import type {
Endpoints,
Expand Down Expand Up @@ -123,25 +123,16 @@ export const signup = async (options: SignupOptions): Promise<SignupResult> => {
// the backend at /checkout/initialize for any new subscription, so we
// validate up front (here, not at the top of signup) to give callers a
// crisp error before the network round trip.
if (!options.email || !options.firstName || !options.lastName) {
const missing = [
!options.email && "email",
!options.firstName && "firstName",
!options.lastName && "lastName",
]
.filter(Boolean)
.join(", ");
throw new Error(`Signup requires contact info. Missing: ${missing}.`);
}
const { email, firstName, lastName } = requireContactInfo(options, "Signup");

const paymentLink = await createPayment({
jwt,
refId,
plan,
period: options.period,
email: options.email,
firstName: options.firstName,
lastName: options.lastName,
email,
firstName,
lastName,
couponCode: options.couponCode,
walletAddress,
paymentHost: options.paymentHost,
Expand Down
46 changes: 46 additions & 0 deletions src/auth/signupHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,49 @@ export function buildEndpoints(apiKey: string) {
devnet: `https://devnet.helius-rpc.com/?api-key=${apiKey}`,
};
}

/**
* Trims a contact field. Returns `undefined` for non-strings and for strings
* that are empty after trimming, so callers can treat both as "not provided".
*/
export function normalizeContactField(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}

export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface ContactInfo {
email: string;
firstName: string;
lastName: string;
}

/**
* Normalizes `email`, `firstName` and `lastName` and requires all three.
* Throws `"<context> requires contact info. Missing: ..."` when any is absent
* (after trimming) and `"Invalid email address."` for a malformed email.
*/
export function requireContactInfo(
fields: { email?: unknown; firstName?: unknown; lastName?: unknown },
context: string
): ContactInfo {
const email = normalizeContactField(fields.email);
const firstName = normalizeContactField(fields.firstName);
const lastName = normalizeContactField(fields.lastName);
if (!email || !firstName || !lastName) {
const missing = [
!email && "email",
!firstName && "firstName",
!lastName && "lastName",
]
.filter(Boolean)
.join(", ");
throw new Error(`${context} requires contact info. Missing: ${missing}.`);
}
if (!EMAIL_PATTERN.test(email)) {
throw new Error("Invalid email address.");
}
return { email, firstName, lastName };
}
135 changes: 7 additions & 128 deletions src/auth/tests/payUSDC.test.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,15 @@
const mockSend = jest.fn();
const mockRpcSubscriptions = { subscribe: jest.fn() };
const mockSendAndConfirm = jest.fn().mockResolvedValue(undefined);
const mockSigner = {
address: "SignerAddress1111111111111111111111111111111",
signTransactions: jest.fn(),
};

jest.mock("@solana/kit", () => {
const actual = jest.requireActual("@solana/kit");
return {
...actual,
createSolanaRpc: jest.fn(() => ({
getLatestBlockhash: jest.fn(() => ({
send: mockSend,
})),
})),
createSolanaRpcSubscriptions: jest.fn(() => mockRpcSubscriptions),
sendAndConfirmTransactionFactory: jest.fn(() => mockSendAndConfirm),
createKeyPairSignerFromBytes: jest.fn().mockResolvedValue(mockSigner),
pipe: jest.fn((...args: any[]) => {
let result = args[0];
for (let i = 1; i < args.length; i++) {
result = args[i](result);
}
return result;
}),
createTransactionMessage: jest.fn(() => ({ instructions: [] })),
setTransactionMessageFeePayerSigner: jest.fn((_, tx) => tx),
setTransactionMessageLifetimeUsingBlockhash: jest.fn((_, tx) => tx),
appendTransactionMessageInstructions: jest.fn((_, tx) => tx),
signTransactionMessageWithSigners: jest.fn().mockResolvedValue({
signatures: {
SignerAddress1111111111111111111111111111111: new Uint8Array(64),
},
}),
getSignatureFromTransaction: jest.fn(() => "tx-signature-abc123"),
};
});

jest.mock("@solana-program/token", () => ({
findAssociatedTokenPda: jest
.fn()
.mockResolvedValue(["MockAta111111111111111111111111111111111111"]),
TOKEN_PROGRAM_ADDRESS: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
getTransferInstruction: jest.fn(() => ({ programAddress: "token" })),
const mockBuildAndSend = jest.fn();
jest.mock("../buildTokenTransfer", () => ({
buildAndSendTokenTransfer: mockBuildAndSend,
}));

import { payUSDC } from "../payUSDC";
import {
createKeyPairSignerFromBytes,
getSignatureFromTransaction,
} from "@solana/kit";
import {
findAssociatedTokenPda,
getTransferInstruction,
} from "@solana-program/token";

const MOCK_SECRET_KEY = new Uint8Array(64);

describe("payUSDC", () => {
beforeEach(() => {
jest.clearAllMocks();
mockSend.mockResolvedValue({
value: {
blockhash: "mockBlockhash11111111111111111111111111111111",
lastValidBlockHeight: 100n,
},
});
});

it("creates a signer from the secret key", async () => {
await payUSDC(MOCK_SECRET_KEY);

expect(createKeyPairSignerFromBytes).toHaveBeenCalledWith(MOCK_SECRET_KEY);
});

it("derives ATAs for sender and treasury", async () => {
await payUSDC(MOCK_SECRET_KEY);

expect(findAssociatedTokenPda).toHaveBeenCalledTimes(2);
// Sender ATA
expect(findAssociatedTokenPda).toHaveBeenCalledWith(
expect.objectContaining({ owner: mockSigner.address })
);
// Treasury ATA
expect(findAssociatedTokenPda).toHaveBeenCalledWith(
expect.objectContaining({
owner: "CEs84tEowsXpH8u4VBf8rJSVgSRypFMfXw9CpGRtQgb6",
})
);
});

it("fetches latest blockhash", async () => {
await payUSDC(MOCK_SECRET_KEY);

expect(mockSend).toHaveBeenCalled();
});

it("builds a transfer instruction with PAYMENT_AMOUNT", async () => {
await payUSDC(MOCK_SECRET_KEY);

expect(getTransferInstruction).toHaveBeenCalledWith(
expect.objectContaining({ amount: 1_000_000n })
);
});

it("sends and confirms the transaction", async () => {
await payUSDC(MOCK_SECRET_KEY);

expect(mockSendAndConfirm).toHaveBeenCalledWith(expect.anything(), {
commitment: "confirmed",
});
});

it("returns the transaction signature", async () => {
const result = await payUSDC(MOCK_SECRET_KEY);

expect(result).toBe("tx-signature-abc123");
expect(getSignatureFromTransaction).toHaveBeenCalled();
});

it("propagates RPC errors", async () => {
mockSend.mockRejectedValue(new Error("RPC unavailable"));

await expect(payUSDC(MOCK_SECRET_KEY)).rejects.toThrow("RPC unavailable");
});

it("propagates send-and-confirm failures", async () => {
mockSendAndConfirm.mockRejectedValue(new Error("Transaction failed"));

await expect(payUSDC(MOCK_SECRET_KEY)).rejects.toThrow(
"Transaction failed"
it("throws without sending any funds", async () => {
await expect(payUSDC(new Uint8Array(64))).rejects.toThrow(
/no longer supported/
);
expect(mockBuildAndSend).not.toHaveBeenCalled();
});
});
Loading
Loading