diff --git a/packages/core/wallet/src/smart-wallet.service.ts b/packages/core/wallet/src/smart-wallet.service.ts index 59b398c..296ea0e 100644 --- a/packages/core/wallet/src/smart-wallet.service.ts +++ b/packages/core/wallet/src/smart-wallet.service.ts @@ -1,10 +1,12 @@ import { Address, + Asset, Transaction, xdr, StrKey, Networks, Contract, + Operation, TransactionBuilder, BASE_FEE, nativeToScVal, @@ -14,8 +16,12 @@ import { convertSignatureDERtoCompact } from '../auth/src/providers/WebAuthNProv import { BrowserCredentialBackend } from './credential-backends/browser.backend'; import type { CredentialBackend, + DeployResult, + DeployWithTrustlineOptions, SmartWalletWebAuthnProvider, + USDCNetwork, } from './types/smart-wallet.types'; +import { USDC_ISSUERS } from './types/smart-wallet.types'; // --------------------------------------------------------------------------- // TTL helpers @@ -1042,15 +1048,38 @@ export class SmartWalletService { // deploy() // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // deploy() — overloads + // ------------------------------------------------------------------------- + /** * Builds the factory deploy invocation internally, simulates it, and returns * the deployed contract address. */ + async deploy( + publicKey65Bytes: Uint8Array, + factory?: string, + network?: Networks | string + ): Promise; + + /** + * Deploys the smart wallet and additionally prepares a USDC trustline + * transaction for the given account, returning both the contract address + * and the unsigned fee-less trustline XDR for fee sponsorship. + */ + async deploy( + publicKey65Bytes: Uint8Array, + factory: string | undefined, + network: Networks | string | undefined, + options: DeployWithTrustlineOptions + ): Promise; + async deploy( publicKey65Bytes: Uint8Array, factory: string = this.factoryContractId, - network: Networks | string = this.network - ): Promise { + network: Networks | string = this.network, + options?: DeployWithTrustlineOptions + ): Promise { if (!factory) { throw new Error('deploy: factory contract address is required'); } @@ -1105,6 +1134,65 @@ export class SmartWalletService { throw new Error('Factory did not return a contract address.'); } + if (options?.autoTrustlineUSDC) { + const trustlineXdr = await this.setupUSDCTrustline( + options.accountId, + options.usdcNetwork + ); + return { contractAddress, trustlineXdr }; + } + return contractAddress; } + + // ------------------------------------------------------------------------- + // setupUSDCTrustline() + // ------------------------------------------------------------------------- + + /** + * Builds an unsigned fee-less ChangeTrust transaction that adds a USDC + * trustline to the given classic Stellar account. + * + * The returned XDR (base64) is intended for the fee sponsorship workflow: + * the account holder must sign the transaction before it can be submitted. + * + * ## Flow + * 1. Resolve the USDC issuer for the requested network. + * 2. Fetch the account's current sequence number from the RPC node. + * 3. Build a classic Stellar transaction with a single `ChangeTrust` + * operation for the USDC asset at maximum limit. + * 4. Return the unsigned XDR for the caller to sign and submit. + * + * @param accountId Classic Stellar G-address that will hold USDC. + * @param network `'testnet'` or `'mainnet'` — selects the USDC issuer. + * @returns Unsigned fee-less transaction XDR (base64). + */ + async setupUSDCTrustline( + accountId: string, + network: USDCNetwork + ): Promise { + if (!accountId) { + throw new Error('setupUSDCTrustline: accountId is required'); + } + if (!network || !(network in USDC_ISSUERS)) { + throw new Error( + "setupUSDCTrustline: network must be 'testnet' or 'mainnet'" + ); + } + + const issuer = USDC_ISSUERS[network]; + const usdcAsset = new Asset('USDC', issuer); + + const account = await this.server.getAccount(accountId); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.network, + }) + .addOperation(Operation.changeTrust({ asset: usdcAsset })) + .setTimeout(300) + .build(); + + return tx.toEnvelope().toXDR('base64'); + } } diff --git a/packages/core/wallet/src/tests/smart-wallet.service.test.ts b/packages/core/wallet/src/tests/smart-wallet.service.test.ts index 71ee140..856bc51 100644 --- a/packages/core/wallet/src/tests/smart-wallet.service.test.ts +++ b/packages/core/wallet/src/tests/smart-wallet.service.test.ts @@ -57,7 +57,12 @@ jest.mock('@stellar/stellar-sdk', () => { const txBuilderInstance = { addOperation: jest.fn().mockReturnThis(), setTimeout: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({ type: 'transaction' }), + build: jest.fn().mockReturnValue({ + type: 'transaction', + toEnvelope: jest.fn().mockReturnValue({ + toXDR: jest.fn(() => 'TRUSTLINE_XDR_BASE64'), + }), + }), }; const TransactionBuilderMock = jest .fn() @@ -68,6 +73,16 @@ jest.mock('@stellar/stellar-sdk', () => { Contract: ContractMock, TransactionBuilder: TransactionBuilderMock, nativeToScVal: jest.fn().mockReturnValue({ type: 'scvU32' }), + Asset: jest.fn().mockImplementation((code: string, issuer: string) => ({ + code, + issuer, + })), + Operation: { + ...actual.Operation, + changeTrust: jest + .fn() + .mockReturnValue({ type: 'changeTrust' }), + }, BASE_FEE: '100', StrKey: { ...actual.StrKey, @@ -149,6 +164,7 @@ describe('SmartWalletService', () => { let mockServer: { simulateTransaction: jest.Mock; getLatestLedger: jest.Mock; + getAccount: jest.Mock; }; let mockCredentialBackend: jest.Mocked; const sorobanTx = {} as unknown as Transaction; @@ -159,6 +175,11 @@ describe('SmartWalletService', () => { mockServer = { simulateTransaction: jest.fn(), getLatestLedger: jest.fn().mockResolvedValue({ sequence: 1000 }), + getAccount: jest.fn().mockResolvedValue({ + accountId: () => 'GABC1234ACCOUNTID', + sequenceNumber: () => '100', + incrementSequenceNumber: () => {}, + }), }; (Server as jest.Mock).mockImplementation(() => mockServer); @@ -856,4 +877,131 @@ describe('SmartWalletService', () => { ).rejects.toThrow('must be 65 bytes'); }); }); + + // ========================================================================= + // setupUSDCTrustline() + // ========================================================================= + + describe('setupUSDCTrustline()', () => { + const ACCOUNT_ID = + 'GABC1EFGHIJKLMNOPQRSTUVWXYZABC1EFGHIJKLMNOPQRSTUVWXYZABC1EF'; + + it('returns XDR for a testnet USDC trustline', async () => { + const result = await service.setupUSDCTrustline(ACCOUNT_ID, 'testnet'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('returns XDR for a mainnet USDC trustline', async () => { + const result = await service.setupUSDCTrustline(ACCOUNT_ID, 'mainnet'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('calls server.getAccount with the given accountId', async () => { + await service.setupUSDCTrustline(ACCOUNT_ID, 'testnet'); + expect(mockServer.getAccount).toHaveBeenCalledWith(ACCOUNT_ID); + }); + + it('creates USDC asset with the testnet issuer', async () => { + await service.setupUSDCTrustline(ACCOUNT_ID, 'testnet'); + const { Asset } = jest.requireMock('@stellar/stellar-sdk'); + expect(Asset).toHaveBeenCalledWith( + 'USDC', + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + ); + }); + + it('creates USDC asset with the mainnet issuer', async () => { + await service.setupUSDCTrustline(ACCOUNT_ID, 'mainnet'); + const { Asset } = jest.requireMock('@stellar/stellar-sdk'); + expect(Asset).toHaveBeenCalledWith( + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + ); + }); + + it('builds a ChangeTrust operation', async () => { + await service.setupUSDCTrustline(ACCOUNT_ID, 'testnet'); + const { Operation } = jest.requireMock('@stellar/stellar-sdk'); + expect(Operation.changeTrust).toHaveBeenCalledWith( + expect.objectContaining({ asset: expect.anything() }) + ); + }); + + it('throws if accountId is empty', async () => { + await expect( + service.setupUSDCTrustline('', 'testnet') + ).rejects.toThrow('setupUSDCTrustline: accountId is required'); + }); + + it('throws if network is invalid', async () => { + await expect( + // @ts-expect-error — intentionally passing invalid value + service.setupUSDCTrustline(ACCOUNT_ID, 'devnet') + ).rejects.toThrow("setupUSDCTrustline: network must be 'testnet' or 'mainnet'"); + }); + + it('throws if server.getAccount rejects', async () => { + mockServer.getAccount.mockRejectedValueOnce(new Error('account not found')); + await expect( + service.setupUSDCTrustline(ACCOUNT_ID, 'testnet') + ).rejects.toThrow('account not found'); + }); + }); + + // ========================================================================= + // deploy() with autoTrustlineUSDC + // ========================================================================= + + describe('deploy() with autoTrustlineUSDC', () => { + const ACCOUNT_ID = + 'GABC1EFGHIJKLMNOPQRSTUVWXYZABC1EFGHIJKLMNOPQRSTUVWXYZABC1EF'; + + beforeEach(() => { + mockServer.simulateTransaction.mockResolvedValue({ + result: { + retval: { + address: () => ({ + contractId: () => ({ toString: () => MOCK_CONTRACT_ADDRESS }), + }), + }, + }, + }); + }); + + it('returns contractAddress and trustlineXdr when autoTrustlineUSDC is true', async () => { + const result = await service.deploy( + publicKey, + MOCK_CONTRACT_ADDRESS, + undefined, + { autoTrustlineUSDC: true, usdcNetwork: 'testnet', accountId: ACCOUNT_ID } + ); + + expect(result).toEqual( + expect.objectContaining({ + contractAddress: MOCK_CONTRACT_ADDRESS, + trustlineXdr: expect.any(String), + }) + ); + }); + + it('calls setupUSDCTrustline with the correct accountId and network', async () => { + const spy = jest.spyOn(service, 'setupUSDCTrustline'); + + await service.deploy( + publicKey, + MOCK_CONTRACT_ADDRESS, + undefined, + { autoTrustlineUSDC: true, usdcNetwork: 'mainnet', accountId: ACCOUNT_ID } + ); + + expect(spy).toHaveBeenCalledWith(ACCOUNT_ID, 'mainnet'); + }); + + it('returns a plain string (contract address) when no options are passed', async () => { + const result = await service.deploy(publicKey, MOCK_CONTRACT_ADDRESS); + expect(result).toBe(MOCK_CONTRACT_ADDRESS); + }); + }); }); diff --git a/packages/core/wallet/src/types/smart-wallet.types.ts b/packages/core/wallet/src/types/smart-wallet.types.ts index 33a8654..7d96e7f 100644 --- a/packages/core/wallet/src/types/smart-wallet.types.ts +++ b/packages/core/wallet/src/types/smart-wallet.types.ts @@ -8,3 +8,37 @@ export interface CredentialBackend { export interface SmartWalletWebAuthnProvider { readonly relyingPartyId: string; } + +/** + * USDC issuer addresses on Stellar. + * Testnet: Circle's test USDC issuer. + * Mainnet: Circle's production USDC issuer. + */ +export const USDC_ISSUERS = { + testnet: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + mainnet: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', +} as const; + +export type USDCNetwork = keyof typeof USDC_ISSUERS; + +/** + * Options for deploy() when the caller wants to also prepare a USDC trustline + * for the account during wallet creation. + */ +export interface DeployWithTrustlineOptions { + autoTrustlineUSDC: true; + /** The network to resolve the correct USDC issuer. */ + usdcNetwork: USDCNetwork; + /** Classic Stellar G-address that will hold USDC. */ + accountId: string; +} + +/** + * Return value from deploy() when autoTrustlineUSDC is requested. + * The trustlineXdr is an unsigned fee-less transaction XDR (base64) + * intended for fee sponsorship — the account must still sign it. + */ +export interface DeployResult { + contractAddress: string; + trustlineXdr: string; +}