From 5b3c9ea6633b0dfb206e7173a275f1a19c35c657 Mon Sep 17 00:00:00 2001 From: V1ctor-o Date: Sat, 30 May 2026 23:02:50 +0100 Subject: [PATCH] feat(sdk): add wallet adapter layer for browser wallet signing --- sdk/README.md | 19 +++++ sdk/src/adapters/albedoAdapter.ts | 32 +++++++ sdk/src/adapters/freighterAdapter.ts | 32 +++++++ sdk/src/adapters/walletConnectAdapter.ts | 28 ++++++ sdk/src/client.ts | 103 ++++++++++++++++++----- sdk/src/index.ts | 5 ++ sdk/src/walletAdapter.test.ts | 15 ++++ sdk/src/walletAdapter.ts | 16 ++++ 8 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 sdk/src/adapters/albedoAdapter.ts create mode 100644 sdk/src/adapters/freighterAdapter.ts create mode 100644 sdk/src/adapters/walletConnectAdapter.ts create mode 100644 sdk/src/walletAdapter.test.ts create mode 100644 sdk/src/walletAdapter.ts diff --git a/sdk/README.md b/sdk/README.md index e53a25f..f747f0c 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -398,6 +398,25 @@ await client.unpause(adminKeypair); ### Write Methods (require Keypair) +## Wallet Adapter (Browser wallets) + +The SDK supports an optional `WalletAdapter` layer so consumers can plug-in browser wallets (Freighter, Albedo, WalletConnect). + +Example using a wallet adapter: + +```typescript +import { bcForgeClient, FreighterAdapter } from '@bc-forge/sdk'; + +const adapter = new FreighterAdapter(); +const client = new bcForgeClient({ rpcUrl, networkPassphrase, contractId, walletAdapter: adapter }); + +await client.connectWallet(); +await client.mint('GRECIPIENT...', BigInt(1000), /* no Keypair */); +``` + +When a `walletAdapter` is configured and connected, write methods may be invoked without passing a `Keypair`; the SDK will build an unsigned transaction and ask the adapter to sign and submit it. + + | Method | Description | |--------|-------------| | `initialize(admin, decimals, name, symbol, source)` | One-time contract setup | diff --git a/sdk/src/adapters/albedoAdapter.ts b/sdk/src/adapters/albedoAdapter.ts new file mode 100644 index 0000000..a2fb0f1 --- /dev/null +++ b/sdk/src/adapters/albedoAdapter.ts @@ -0,0 +1,32 @@ +import { WalletAdapter } from '../walletAdapter'; + +export class AlbedoAdapter implements WalletAdapter { + name = 'albedo'; + connected = false; + publicKey?: string; + + async connect(): Promise { + const albedo = (globalThis as any).albedo; + if (!albedo) throw new Error('Albedo not available in this environment'); + const resp = await albedo.publicKey(); + if (!resp) throw new Error('Albedo did not return a public key'); + this.publicKey = resp; + this.connected = true; + } + + async disconnect(): Promise { + this.publicKey = undefined; + this.connected = false; + } + + async signTransaction(unsignedTxXdr: string): Promise { + const albedo = (globalThis as any).albedo; + if (!albedo) throw new Error('Albedo not available in this environment'); + // Albedo's signing interface varies; attempt common patterns + const signed = await albedo.signTransaction?.(unsignedTxXdr) || (await albedo.signTx?.(unsignedTxXdr)); + if (!signed) throw new Error('Albedo failed to sign transaction'); + return signed.xdr || signed; + } +} + +export default AlbedoAdapter; diff --git a/sdk/src/adapters/freighterAdapter.ts b/sdk/src/adapters/freighterAdapter.ts new file mode 100644 index 0000000..d828189 --- /dev/null +++ b/sdk/src/adapters/freighterAdapter.ts @@ -0,0 +1,32 @@ +import { WalletAdapter } from '../walletAdapter'; + +export class FreighterAdapter implements WalletAdapter { + name = 'freighter'; + connected = false; + publicKey?: string; + + async connect(): Promise { + const api = (globalThis as any).freighter; + if (!api) throw new Error('Freighter API not available in this environment'); + const pk = await api.getPublicKey?.(); + if (!pk) throw new Error('Freighter did not return a public key'); + this.publicKey = pk; + this.connected = true; + } + + async disconnect(): Promise { + this.publicKey = undefined; + this.connected = false; + } + + async signTransaction(unsignedTxXdr: string): Promise { + const api = (globalThis as any).freighter; + if (!api) throw new Error('Freighter API not available in this environment'); + const resp = await api.signTransaction(unsignedTxXdr); + if (!resp) throw new Error('Freighter failed to sign transaction'); + // Freighter returns an object in some versions; try to read xdr or raw + return resp.xdr || resp.signedXdr || resp; + } +} + +export default FreighterAdapter; diff --git a/sdk/src/adapters/walletConnectAdapter.ts b/sdk/src/adapters/walletConnectAdapter.ts new file mode 100644 index 0000000..8e151b5 --- /dev/null +++ b/sdk/src/adapters/walletConnectAdapter.ts @@ -0,0 +1,28 @@ +import { WalletAdapter } from '../walletAdapter'; + +/** + * Minimal stub for WalletConnect-based signing. Concrete implementation + * should be provided by consumers integrating WalletConnect + Stellar signing. + */ +export class WalletConnectAdapter implements WalletAdapter { + name = 'walletconnect'; + connected = false; + publicKey?: string; + + async connect(): Promise { + // WalletConnect integration is app-specific. Provide a stub that + // instructs consumers to implement the actual flow. + throw new Error('WalletConnectAdapter.connect() not implemented'); + } + + async disconnect(): Promise { + this.connected = false; + this.publicKey = undefined; + } + + async signTransaction(_unsignedTxXdr: string): Promise { + throw new Error('WalletConnectAdapter.signTransaction() not implemented'); + } +} + +export default WalletConnectAdapter; diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5afb91c..efea038 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -13,6 +13,7 @@ import { xdr, nativeToScVal, } from '@stellar/stellar-sdk'; +import type { WalletAdapter } from './walletAdapter'; import { buildInvokeTransaction, @@ -39,6 +40,8 @@ export interface bcForgeClientConfig { networkPassphrase: string; /** Deployed bc-forge token contract ID */ contractId: string; + /** Optional wallet adapter for browser-based signing flows */ + walletAdapter?: WalletAdapter; } export interface TransactionResult { @@ -65,6 +68,7 @@ export class bcForgeClient { private contractId: string; private server: SorobanRpc.Server; private contract: Contract; + private walletAdapter?: WalletAdapter; constructor(config: bcForgeClientConfig) { this.rpcUrl = config.rpcUrl; @@ -72,6 +76,25 @@ export class bcForgeClient { this.contractId = config.contractId; this.server = new SorobanRpc.Server(this.rpcUrl); this.contract = new Contract(this.contractId); + this.walletAdapter = config.walletAdapter; + } + + /** Replace or set the wallet adapter at runtime */ + setWalletAdapter(adapter?: WalletAdapter) { + this.walletAdapter = adapter; + } + + /** Connect the configured wallet adapter (if any) */ + async connectWallet(): Promise { + if (!this.walletAdapter) throw new Error('No wallet adapter configured'); + await this.walletAdapter.connect(); + return this.walletAdapter.publicKey; + } + + /** Disconnect the configured wallet adapter (if any) */ + async disconnectWallet(): Promise { + if (!this.walletAdapter) return; + await this.walletAdapter.disconnect(); } // ─── Read-Only Queries ─────────────────────────────────────────────────── @@ -186,7 +209,7 @@ export class bcForgeClient { decimals: number, name: string, symbol: string, - source: Keypair, + source?: Keypair, ): Promise { return this.invokeContract( 'initialize', @@ -202,7 +225,7 @@ export class bcForgeClient { * @param amount - Number of tokens to mint * @param source - Admin keypair */ - async mint(to: string, amount: bigint, source: Keypair): Promise { + async mint(to: string, amount: bigint, source?: Keypair): Promise { return this.invokeContract('mint', [addressToScVal(to), i128ToScVal(amount)], source); } @@ -212,7 +235,7 @@ export class bcForgeClient { * @param recipients - Array of recipient objects * @param source - Admin keypair */ - async batchMint(recipients: BatchMintRecipient[], source: Keypair): Promise { + async batchMint(recipients: BatchMintRecipient[], source?: Keypair): Promise { const recipientScVals = recipients.map(({ to, amount }) => xdr.ScVal.scvMap([ new xdr.ScMapEntry({ @@ -241,7 +264,7 @@ export class bcForgeClient { from: string, to: string, amount: bigint, - source: Keypair, + source?: Keypair, ): Promise { return this.invokeContract( 'transfer', @@ -262,7 +285,7 @@ export class bcForgeClient { from: string, spender: string, amount: bigint, - source: Keypair, + source?: Keypair, ): Promise { return this.invokeContract( 'approve', @@ -283,7 +306,7 @@ export class bcForgeClient { * @param amount - Number of tokens to burn * @param source - Burner's keypair */ - async burn(from: string, amount: bigint, source: Keypair): Promise { + async burn(from: string, amount: bigint, source?: Keypair): Promise { return this.invokeContract('burn', [addressToScVal(from), i128ToScVal(amount)], source); } @@ -293,7 +316,7 @@ export class bcForgeClient { * @param newAdmin - New admin address * @param source - Current admin's keypair */ - async transferOwnership(newAdmin: string, source: Keypair): Promise { + async transferOwnership(newAdmin: string, source?: Keypair): Promise { return this.invokeContract('transfer_ownership', [addressToScVal(newAdmin)], source); } @@ -302,7 +325,7 @@ export class bcForgeClient { * * @param source - Admin keypair */ - async pause(source: Keypair): Promise { + async pause(source?: Keypair): Promise { return this.invokeContract('pause', [], source); } @@ -311,7 +334,7 @@ export class bcForgeClient { * * @param source - Admin keypair */ - async unpause(source: Keypair): Promise { + async unpause(source?: Keypair): Promise { return this.invokeContract('unpause', [], source); } @@ -504,7 +527,7 @@ export class bcForgeClient { * @param newWasmHash - 32-byte hex string or Buffer of the new WASM hash * @param source - Admin keypair */ - async upgrade(newWasmHash: string | Buffer, source: Keypair): Promise { + async upgrade(newWasmHash: string | Buffer, source?: Keypair): Promise { return this.invokeContract('upgrade', [hashToScVal(newWasmHash)], source); } @@ -520,7 +543,7 @@ export class bcForgeClient { admin: string, action: { Mint: [string, bigint] } | { Pause: [] } | { Unpause: [] }, description: string, - source: Keypair, + source?: Keypair, ): Promise { const actionScVal = 'Mint' in action @@ -542,7 +565,7 @@ export class bcForgeClient { async approveProposal( admin: string, proposalId: bigint, - source: Keypair, + source?: Keypair, ): Promise { return this.invokeContract( 'approve_proposal', @@ -554,7 +577,7 @@ export class bcForgeClient { /** * Execute a proposal once quorum is reached. */ - async executeProposal(proposalId: bigint, source: Keypair): Promise { + async executeProposal(proposalId: bigint, source?: Keypair): Promise { return this.invokeContract( 'execute_proposal', [nativeToScVal(proposalId, { type: 'u64' })], @@ -567,7 +590,7 @@ export class bcForgeClient { /** * Set the designated clawback administrator. */ - async setClawbackAdmin(admin: string, source: Keypair): Promise { + async setClawbackAdmin(admin: string, source?: Keypair): Promise { return this.invokeContract('set_clawback_admin', [addressToScVal(admin)], source); } @@ -577,7 +600,7 @@ export class bcForgeClient { * @param newName - The new token name * @param source - Admin keypair */ - async updateName(newName: string, source: Keypair): Promise { + async updateName(newName: string, source?: Keypair): Promise { return this.invokeContract('update_name', [stringToScVal(newName)], source); } @@ -588,7 +611,7 @@ export class bcForgeClient { from: string, to: string, amount: bigint, - source: Keypair, + source?: Keypair, ): Promise { return this.invokeContract( 'clawback', @@ -606,7 +629,7 @@ export class bcForgeClient { user: string, amount: bigint, unlockTime: bigint, - source: Keypair, + source?: Keypair, ): Promise { return this.invokeContract( 'lock_tokens', @@ -618,7 +641,7 @@ export class bcForgeClient { /** * Withdraw matured locked tokens. */ - async withdrawLocked(user: string, source: Keypair): Promise { + async withdrawLocked(user: string, source?: Keypair): Promise { return this.invokeContract('withdraw_locked', [addressToScVal(user)], source); } @@ -641,7 +664,7 @@ export class bcForgeClient { * @param newSymbol - The new token symbol * @param source - Admin keypair */ - async updateSymbol(newSymbol: string, source: Keypair): Promise { + async updateSymbol(newSymbol: string, source?: Keypair): Promise { return this.invokeContract('update_symbol', [stringToScVal(newSymbol)], source); } @@ -710,20 +733,54 @@ export class bcForgeClient { private async invokeContract( method: string, args: xdr.ScVal[], - source: Keypair, + source?: Keypair, ): Promise { return this.withRetry(async () => { try { - const txXdr = await buildInvokeTransaction( + // If an explicit Keypair is provided, use the existing signed builder + if (source) { + const txXdr = await buildInvokeTransaction( + this.rpcUrl, + this.networkPassphrase, + this.contractId, + method, + args, + source, + ); + + const response = await submitTransaction(this.rpcUrl, txXdr); + + if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return { + success: true, + hash: (response as any).hash, + returnValue: response.returnValue ? scValToNative(response.returnValue) : undefined, + }; + } + + return { + success: false, + hash: (response as any).hash, + }; + } + + // Otherwise, attempt to use the configured wallet adapter + if (!this.walletAdapter) throw new Error('No signing source provided'); + if (!this.walletAdapter.connected || !this.walletAdapter.publicKey) + throw new Error('Wallet adapter not connected'); + + const unsignedXdr = await buildUnsignedTransaction( this.rpcUrl, this.networkPassphrase, this.contractId, method, args, - source, + this.walletAdapter.publicKey, ); - const response = await submitTransaction(this.rpcUrl, txXdr); + const signedXdr = await this.walletAdapter.signTransaction(unsignedXdr); + + const response = await submitTransaction(this.rpcUrl, signedXdr); if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { return { diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d88a55e..60968e9 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -24,3 +24,8 @@ export { buildInvokeTransaction, submitTransaction, scValToNative } from './util export { bcForgeEventType, decodeEvent, decodeDiagnosticEvent, subscribeEvents } from './events'; export type { bcForgeEvent, SubscriptionOptions } from './events'; export * from './mockClient'; + +export type { WalletAdapter } from './walletAdapter'; +export { FreighterAdapter } from './adapters/freighterAdapter'; +export { AlbedoAdapter } from './adapters/albedoAdapter'; +export { WalletConnectAdapter } from './adapters/walletConnectAdapter'; diff --git a/sdk/src/walletAdapter.test.ts b/sdk/src/walletAdapter.test.ts new file mode 100644 index 0000000..e256908 --- /dev/null +++ b/sdk/src/walletAdapter.test.ts @@ -0,0 +1,15 @@ +import { FreighterAdapter, AlbedoAdapter, WalletConnectAdapter } from './index'; + +describe('Wallet adapters basic surface', () => { + it('provides adapter classes', () => { + expect(typeof FreighterAdapter).toBe('function'); + expect(typeof AlbedoAdapter).toBe('function'); + expect(typeof WalletConnectAdapter).toBe('function'); + }); + + it('WalletConnectAdapter throws for unimplemented methods', async () => { + const wc = new WalletConnectAdapter(); + await expect(wc.connect()).rejects.toThrow(); + await expect(wc.signTransaction('x')).rejects.toThrow(); + }); +}); diff --git a/sdk/src/walletAdapter.ts b/sdk/src/walletAdapter.ts new file mode 100644 index 0000000..257d2e2 --- /dev/null +++ b/sdk/src/walletAdapter.ts @@ -0,0 +1,16 @@ +export interface WalletAdapter { + /** Human readable adapter name */ + name: string; + /** Whether the wallet is currently connected */ + connected: boolean; + /** Connected wallet public key (G... address) if connected */ + publicKey?: string; + + /** Connect to the wallet (popups, permissions, etc) */ + connect(): Promise; + /** Disconnect from the wallet */ + disconnect(): Promise; + /** Sign an unsigned transaction XDR and return signed XDR */ + signTransaction(unsignedTxXdr: string): Promise; +} +