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
92 changes: 90 additions & 2 deletions packages/core/wallet/src/smart-wallet.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
Address,
Asset,
Transaction,
xdr,
StrKey,
Networks,
Contract,
Operation,
TransactionBuilder,
BASE_FEE,
nativeToScVal,
Expand All @@ -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
Expand Down Expand Up @@ -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<string>;

/**
* 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<DeployResult>;

async deploy(
publicKey65Bytes: Uint8Array,
factory: string = this.factoryContractId,
network: Networks | string = this.network
): Promise<string> {
network: Networks | string = this.network,
options?: DeployWithTrustlineOptions
): Promise<string | DeployResult> {
if (!factory) {
throw new Error('deploy: factory contract address is required');
}
Expand Down Expand Up @@ -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<string> {
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();
Comment on lines +1183 to +1194
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Network passphrase may not match the USDC issuer network.

The method resolves the USDC issuer based on the network parameter ('testnet' or 'mainnet'), but the transaction is built using this.network (the service's configured network passphrase). If the service was initialized with Networks.TESTNET but setupUSDCTrustline is called with 'mainnet', the resulting transaction will have the testnet passphrase but reference the mainnet USDC issuerβ€”making it invalid on both networks.

Consider deriving the network passphrase from the network parameter to ensure consistency:

πŸ”§ Proposed fix
+import { Networks } from '@stellar/stellar-sdk';
+
+// Inside setupUSDCTrustline:
 const issuer = USDC_ISSUERS[network];
 const usdcAsset = new Asset('USDC', issuer);
+const networkPassphrase = network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET;
 
 const account = await this.server.getAccount(accountId);
 
 const tx = new TransactionBuilder(account, {
   fee: BASE_FEE,
-  networkPassphrase: this.network,
+  networkPassphrase,
 })
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
const issuer = USDC_ISSUERS[network];
const usdcAsset = new Asset('USDC', issuer);
const networkPassphrase = network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET;
const account = await this.server.getAccount(accountId);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase,
})
.addOperation(Operation.changeTrust({ asset: usdcAsset }))
.setTimeout(300)
.build();
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/wallet/src/smart-wallet.service.ts` around lines 1183 - 1194,
The transaction uses this.network (service-wide passphrase) while the USDC
issuer is selected from USDC_ISSUERS[network], causing a mismatch if the
method's network param differs from the service config; update the code in
setupUSDCTrustline (or the function creating the transaction) to derive a
network passphrase from the method parameter (e.g., const networkPassphrase =
network === 'testnet' ? Networks.TESTNET : Networks.PUBLIC) and pass that into
new TransactionBuilder(..., { networkPassphrase }) so the TransactionBuilder
uses the same network as the USDC_ISSUERS lookup; also add an import for
Networks from the Stellar SDK if not already present.


return tx.toEnvelope().toXDR('base64');
}
}
150 changes: 149 additions & 1 deletion packages/core/wallet/src/tests/smart-wallet.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -149,6 +164,7 @@ describe('SmartWalletService', () => {
let mockServer: {
simulateTransaction: jest.Mock;
getLatestLedger: jest.Mock;
getAccount: jest.Mock;
};
let mockCredentialBackend: jest.Mocked<CredentialBackend>;
const sorobanTx = {} as unknown as Transaction;
Expand All @@ -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);

Expand Down Expand Up @@ -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);
});
});
});
34 changes: 34 additions & 0 deletions packages/core/wallet/src/types/smart-wallet.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading