Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
807 changes: 804 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,8 @@
"jest": "^30.0.0",
"ts-jest": "^29.2.0",
"typescript": "^5.6.0"
},
"dependencies": {
"@stellar/stellar-sdk": "^13.3.0"
}
}
146 changes: 146 additions & 0 deletions src/__tests__/signers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
Account,
Asset,
BASE_FEE,
Keypair,
Networks,
Operation,
TransactionBuilder,
} from '@stellar/stellar-sdk';
import { VeroError, VeroErrorCode } from '../errors';
import {
FreighterSigner,
KeypairSigner,
RabetSigner,
type FreighterApi,
type RabetApi,
type Signer,
} from '../signers';

const unsignedXdr = (): string => {
const source = Keypair.random();
return new TransactionBuilder(new Account(source.publicKey(), '1'), {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(
Operation.payment({
destination: Keypair.random().publicKey(),
asset: Asset.native(),
amount: '1',
}),
)
.setTimeout(30)
.build()
.toXDR();
};

const codeOf = async (promise: Promise<unknown>): Promise<VeroErrorCode> => {
try {
await promise;
throw new Error('Expected signer to reject');
} catch (error) {
expect(error).toBeInstanceOf(VeroError);
return (error as VeroError).code;
}
};

describe('wallet signers', () => {
it('reports absent wallet globals with the same code', async () => {
const freighter = new FreighterSigner(undefined);
const rabet = new RabetSigner(undefined);

expect(await codeOf(freighter.getPublicKey())).toBe(VeroErrorCode.WalletUnavailable);
expect(await codeOf(rabet.getPublicKey())).toBe(VeroErrorCode.WalletUnavailable);
});

it('normalizes cancellation identically across wallet adapters', async () => {
const freighterApi: FreighterApi = {
getAddress: jest.fn(),
getNetworkDetails: jest.fn().mockResolvedValue({ networkPassphrase: Networks.TESTNET }),
signTransaction: jest.fn().mockRejectedValue(new Error('User declined the request')),
};
const rabetApi: RabetApi = {
connect: jest.fn(),
getNetwork: jest.fn().mockResolvedValue(Networks.TESTNET),
sign: jest.fn().mockRejectedValue(new Error('Request was rejected by the user')),
};
const request = { transactionXdr: unsignedXdr(), networkPassphrase: Networks.TESTNET };

expect(await codeOf(new FreighterSigner(freighterApi).signTransaction(request))).toBe(
VeroErrorCode.UserRejected,
);
expect(await codeOf(new RabetSigner(rabetApi).signTransaction(request))).toBe(
VeroErrorCode.UserRejected,
);
});

it('refuses a network mismatch before either wallet prompts for a signature', async () => {
const freighterApi: FreighterApi = {
getAddress: jest.fn(),
getNetworkDetails: jest.fn().mockResolvedValue({ networkPassphrase: Networks.PUBLIC }),
signTransaction: jest.fn(),
};
const rabetApi: RabetApi = {
connect: jest.fn(),
getNetwork: jest.fn().mockResolvedValue(Networks.PUBLIC),
sign: jest.fn(),
};
const request = { transactionXdr: unsignedXdr(), networkPassphrase: Networks.TESTNET };

expect(await codeOf(new FreighterSigner(freighterApi).signTransaction(request))).toBe(
VeroErrorCode.NetworkMismatch,
);
expect(await codeOf(new RabetSigner(rabetApi).signTransaction(request))).toBe(
VeroErrorCode.NetworkMismatch,
);
expect(freighterApi.signTransaction).not.toHaveBeenCalled();
expect(rabetApi.sign).not.toHaveBeenCalled();
});
});

describe('KeypairSigner', () => {
it('implements Signer and creates a valid signed transaction', async () => {
const keypair = Keypair.random();
const signer: Signer = new KeypairSigner(keypair.secret());
const signedXdr = await signer.signTransaction({
transactionXdr: unsignedXdr(),
networkPassphrase: Networks.TESTNET,
});

expect(await signer.getPublicKey()).toBe(keypair.publicKey());
expect(TransactionBuilder.fromXDR(signedXdr, Networks.TESTNET).signatures).toHaveLength(1);
});

it('redacts the secret from strings, JSON, object inspection, errors, and logs', async () => {
const keypair = Keypair.random();
const secret = keypair.secret();
const signer = new KeypairSigner(secret);
const log = jest.spyOn(console, 'log').mockImplementation(() => undefined);

const representations = [
signer.toString(),
JSON.stringify(signer),
JSON.stringify(Object.getOwnPropertyDescriptors(signer)),
];
const error = await signer
.signTransaction({ transactionXdr: 'not-xdr', networkPassphrase: Networks.TESTNET })
.catch((caught: unknown) => caught);
representations.push(String(error), JSON.stringify(error));

expect(representations.join(' ')).not.toContain(secret);
expect(JSON.stringify(signer)).toContain('[REDACTED]');
expect(log).not.toHaveBeenCalled();
log.mockRestore();
});

it('does not include an invalid secret in its constructor error', () => {
const secret = 'definitely-not-a-secret';
expect(() => new KeypairSigner(secret)).toThrow(VeroError);
try {
new KeypairSigner(secret);
} catch (error) {
expect(`${String(error)} ${JSON.stringify(error)}`).not.toContain(secret);
}
});
});
2 changes: 2 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export enum VeroErrorCode {
UserRejected = 'USER_REJECTED',
/** No wallet extension detected. */
WalletUnavailable = 'WALLET_UNAVAILABLE',
/** The wallet is connected to a different Stellar network. */
NetworkMismatch = 'NETWORK_MISMATCH',
/** Transaction rejected by the network. */
TransactionFailed = 'TRANSACTION_FAILED',
/** Sequence number was already consumed. */
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './errors';
export * from './network';
export * from './rpc';
export * from './nonce';
export * from './signers';
54 changes: 54 additions & 0 deletions src/signers/freighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { normalizeError, VeroErrorCode } from '../errors';
import { assertNetwork, unavailable } from './shared';
import type { Signer, SignTransactionRequest } from './types';

export interface FreighterApi {
getAddress(): Promise<{ address: string; error?: string }>;
getNetworkDetails(): Promise<{ networkPassphrase: string; error?: string }>;
signTransaction(
transactionXdr: string,
options: { networkPassphrase: string },
): Promise<{ signedTxXdr: string; error?: string }>;
}

function globalFreighter(): FreighterApi | undefined {
return (globalThis as typeof globalThis & { freighterApi?: FreighterApi }).freighterApi;
}

/** Browser signer backed by the Freighter extension. */
export class FreighterSigner implements Signer {
readonly #api?: FreighterApi;

constructor(api: FreighterApi | undefined = globalFreighter()) {
this.#api = api;
}

async getPublicKey(): Promise<string> {
const api = this.#api;
if (!api) throw unavailable('Freighter');
try {
const result = await api.getAddress();
if (result.error) throw new Error(result.error);
return result.address;
} catch (error) {
throw normalizeError(error, VeroErrorCode.Unknown);
}
}

async signTransaction(request: SignTransactionRequest): Promise<string> {
const api = this.#api;
if (!api) throw unavailable('Freighter');
try {
const network = await api.getNetworkDetails();
if (network.error) throw new Error(network.error);
assertNetwork(request.networkPassphrase, network.networkPassphrase);
const result = await api.signTransaction(request.transactionXdr, {
networkPassphrase: request.networkPassphrase,
});
if (result.error) throw new Error(result.error);
return result.signedTxXdr;
} catch (error) {
throw normalizeError(error, VeroErrorCode.Unknown);
}
}
}
4 changes: 4 additions & 0 deletions src/signers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './types';
export * from './freighter';
export * from './rabet';
export * from './keypair';
43 changes: 43 additions & 0 deletions src/signers/keypair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Keypair, TransactionBuilder } from '@stellar/stellar-sdk';
import { normalizeError, VeroErrorCode } from '../errors';
import type { Signer, SignTransactionRequest } from './types';

/** Server-side signer whose secret is held only inside an ECMAScript private field. */
export class KeypairSigner implements Signer {
readonly #keypair: Keypair;

constructor(secret: string) {
try {
this.#keypair = Keypair.fromSecret(secret);
} catch {
// Do not retain the input or attach an SDK error that might echo it.
throw normalizeError(new Error('Invalid Stellar secret key'), VeroErrorCode.Unknown);
}
}

async getPublicKey(): Promise<string> {
return this.#keypair.publicKey();
}

async signTransaction(request: SignTransactionRequest): Promise<string> {
try {
const transaction = TransactionBuilder.fromXDR(
request.transactionXdr,
request.networkPassphrase,
);
transaction.sign(this.#keypair);
return transaction.toXDR();
} catch {
// Deliberately omit the cause: dependency errors must never capture secret state.
throw normalizeError(new Error('Unable to sign Stellar transaction'), VeroErrorCode.Unknown);
}
}

toString(): string {
return '[KeypairSigner REDACTED]';
}

toJSON(): { type: string; secret: string } {
return { type: 'KeypairSigner', secret: '[REDACTED]' };
}
}
48 changes: 48 additions & 0 deletions src/signers/rabet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { normalizeError, VeroErrorCode } from '../errors';
import { assertNetwork, unavailable } from './shared';
import type { Signer, SignTransactionRequest } from './types';

export interface RabetApi {
connect(): Promise<{ publicKey: string }>;
getNetwork(): Promise<string | { networkPassphrase: string }>;
sign(transactionXdr: string, networkPassphrase: string): Promise<string | { xdr: string }>;
}

function globalRabet(): RabetApi | undefined {
return (globalThis as typeof globalThis & { rabet?: RabetApi }).rabet;
}

/** Browser signer backed by the Rabet extension. */
export class RabetSigner implements Signer {
readonly #api?: RabetApi;

constructor(api: RabetApi | undefined = globalRabet()) {
this.#api = api;
}

async getPublicKey(): Promise<string> {
const api = this.#api;
if (!api) throw unavailable('Rabet');
try {
return (await api.connect()).publicKey;
} catch (error) {
throw normalizeError(error, VeroErrorCode.Unknown);
}
}

async signTransaction(request: SignTransactionRequest): Promise<string> {
const api = this.#api;
if (!api) throw unavailable('Rabet');
try {
const network = await api.getNetwork();
assertNetwork(
request.networkPassphrase,
typeof network === 'string' ? network : network.networkPassphrase,
);
const result = await api.sign(request.transactionXdr, request.networkPassphrase);
return typeof result === 'string' ? result : result.xdr;
} catch (error) {
throw normalizeError(error, VeroErrorCode.Unknown);
}
}
}
14 changes: 14 additions & 0 deletions src/signers/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { VeroError, VeroErrorCode } from '../errors';

export function assertNetwork(expected: string, actual: string): void {
if (actual !== expected) {
throw new VeroError(
VeroErrorCode.NetworkMismatch,
'Wallet network does not match the transaction network',
);
}
}

export function unavailable(wallet: string): VeroError {
return new VeroError(VeroErrorCode.WalletUnavailable, `${wallet} wallet is not available`);
}
13 changes: 13 additions & 0 deletions src/signers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Input shared by every transaction signer. */
export interface SignTransactionRequest {
/** Base64-encoded Stellar transaction envelope XDR. */
transactionXdr: string;
/** Stellar network passphrase the transaction was built for. */
networkPassphrase: string;
}

/** A wallet or server-side identity capable of signing Stellar transactions. */
export interface Signer {
getPublicKey(): Promise<string>;
signTransaction(request: SignTransactionRequest): Promise<string>;
}
Loading