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
4 changes: 4 additions & 0 deletions modules/sdk-coin-starknet/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const OZ_ETH_ACCOUNT_CLASS_HASH = '0x3940bc18abf1df6bc540cabadb1cad9486c6
// STRK token contract (same on both mainnet and sepolia)
export const STRK_TOKEN_CONTRACT = '0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d';

// UDC — same address on mainnet and Sepolia
export const UDC_ADDRESS = '0x041a78e741e5af2fec34b695679bc6891742439f7afb8484ecd7766661ad02bf';
export const UDC_DEPLOY_ENTRYPOINT = 'deployContract';

// felt252 max value (2^251 + 17 * 2^192 + 1)
export const FELT_MAX = (1n << 251n) + 17n * (1n << 192n) + 1n;

Expand Down
15 changes: 15 additions & 0 deletions modules/sdk-coin-starknet/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ export interface ParsedTransferData {
tokenContract: string;
}

export interface ParsedUdcDeployData {
classHash: string;
salt: string;
/** Must be false for BitGo address match (deployer=0). */
unique: boolean;
constructorCalldata: string[];
udcAddress: string;
}

export interface UdcDeployParams {
classHash: string;
salt: string;
constructorCalldata: string[];
}

export interface TxData {
id?: string;
sender: string;
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-starknet/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './iface';
export { KeyPair } from './keyPair';
export { TransactionBuilder } from './transactionBuilder';
export { TransferBuilder } from './transferBuilder';
export { UdcDeployBuilder } from './udcDeployBuilder';
export { WalletInitializationBuilder } from './walletInitializationBuilder';
export { TransactionBuilderFactory } from './transactionBuilderFactory';
export { Transaction } from './transaction';
Expand Down
42 changes: 32 additions & 10 deletions modules/sdk-coin-starknet/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
StarknetTransactionExplanation,
TxData,
} from './iface';
import utils, { compileExecuteCalldata, parseTransferCall } from './utils';
import utils, { compileExecuteCalldata, parseTransferCall, parseUdcDeployCall } from './utils';

function resolveCompiledCalldata(data: StarknetTransactionData): string[] {
if (data.compiledCalldata && data.compiledCalldata.length > 0) {
Expand Down Expand Up @@ -104,18 +104,40 @@ export class Transaction extends BaseTransaction {
if (!this._starknetTransactionData) {
throw new InvalidTransactionError('Empty transaction');
}
const transfer =
this._starknetTransactionData.calls.length > 0
? parseTransferCall(this._starknetTransactionData.calls[0])
: undefined;
return {

const data = this._starknetTransactionData;
const result: TxData = {
id: this._id,
sender: this._starknetTransactionData.senderAddress,
recipient: transfer?.recipient,
amount: transfer?.amount,
nonce: this._starknetTransactionData.nonce,
sender: data.senderAddress,
nonce: data.nonce,
type: TransactionType.Send,
};

const firstCall = data.calls[0];
if (!firstCall) {
return result;
}

const transfer = parseTransferCall(firstCall);
if (transfer) {
result.recipient = transfer.recipient;
result.amount = transfer.amount;
return result;
}

const udcDeploy = parseUdcDeployCall(firstCall);
if (udcDeploy && !udcDeploy.unique) {
result.recipient = utils.calculateContractAddressFromHash(
udcDeploy.salt,
udcDeploy.classHash,
udcDeploy.constructorCalldata,
0
);
result.amount = '0';
result.type = TransactionType.ContractCall;
}

return result;
}

/** @inheritDoc */
Expand Down
13 changes: 12 additions & 1 deletion modules/sdk-coin-starknet/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { Transaction } from './transaction';
import { TransactionBuilder } from './transactionBuilder';
import { TransferBuilder } from './transferBuilder';
import { UdcDeployBuilder } from './udcDeployBuilder';
import { WalletInitializationBuilder } from './walletInitializationBuilder';
import { StarknetTransactionType } from './iface';
import utils from './utils';

export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
constructor(_coinConfig: Readonly<CoinConfig>) {
Expand All @@ -17,8 +19,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
await transaction.fromRawTransaction(rawTransaction);
try {
switch (transaction.starknetTransactionData.transactionType) {
case StarknetTransactionType.INVOKE:
case StarknetTransactionType.INVOKE: {
const calls = transaction.starknetTransactionData.calls || [];
if (calls.length === 1 && utils.isUdcDeployCall(calls[0])) {
return this.getUdcDeployBuilder(transaction);
}
return this.getTransferBuilder(transaction);
}
case StarknetTransactionType.DEPLOY_ACCOUNT:
return this.getWalletInitializationBuilder(transaction);
default:
Expand All @@ -41,6 +48,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return TransactionBuilderFactory.initializeBuilder(tx, new TransferBuilder(this._coinConfig));
}

getUdcDeployBuilder(tx?: Transaction): UdcDeployBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new UdcDeployBuilder(this._coinConfig));
}

/** @inheritdoc */
getWalletInitializationBuilder(tx?: Transaction): WalletInitializationBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new WalletInitializationBuilder(this._coinConfig));
Expand Down
139 changes: 139 additions & 0 deletions modules/sdk-coin-starknet/src/lib/udcDeployBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { BuildTransactionError } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { TransactionBuilder } from './transactionBuilder';
import { Transaction } from './transaction';
import { StarknetCall, StarknetTransactionType, UdcDeployParams } from './iface';
import { OZ_ETH_ACCOUNT_CLASS_HASH, UDC_ADDRESS, UDC_DEPLOY_ENTRYPOINT } from './constants';
import utils from './utils';

/**
* INVOKE to UDC `deployContract` from a master wallet.
* Always uses unique=0 so the address matches `computeStarknetAddress` (deployer=0).
* `fromPublicKey` sets the *target* only — call `sender` with the master address separately.
*/
export class UdcDeployBuilder extends TransactionBuilder {
protected _classHash: string = OZ_ETH_ACCOUNT_CLASS_HASH;
protected _salt?: string;
protected _constructorCalldata?: string[];
protected _targetAddress?: string;

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
}

protected get transactionType(): StarknetTransactionType {
return StarknetTransactionType.INVOKE;
}

/** Sets deploy params and derives the counterfactual target address. */
public deployParams(params: UdcDeployParams): this {
if (!params.classHash || !utils.isValidAddress(params.classHash)) {
throw new BuildTransactionError('Invalid class hash, got: ' + params.classHash);
}
if (!params.salt || !utils.isValidAddress(params.salt)) {
throw new BuildTransactionError('Invalid salt, got: ' + params.salt);
}
if (!params.constructorCalldata || params.constructorCalldata.length === 0) {
throw new BuildTransactionError('Constructor calldata is required');
}
for (const felt of params.constructorCalldata) {
// Limbs may be 0x0 (e.g. pubkey x/y halves); only require 0x-hex form.
if (!felt || !/^0x[0-9a-fA-F]+$/i.test(felt)) {
throw new BuildTransactionError('Invalid constructor calldata felt, got: ' + felt);
}
}

this._classHash = params.classHash;
this._salt = params.salt;
this._constructorCalldata = params.constructorCalldata;
this._targetAddress = utils.calculateContractAddressFromHash(
params.salt,
params.classHash,
params.constructorCalldata,
0
);
return this;
}

/** Derive deploy params from the target user's pubkey. Does not set sender. */
public fromPublicKey(pubKey: string): this {
if (!utils.isValidPublicKey(pubKey)) {
throw new BuildTransactionError('Invalid pubKey, got: ' + pubKey);
}
const fullPublicKey = utils.getUncompressedPublicKey(pubKey);
const { address, constructorCalldata, salt } = utils.computeStarknetAddress(fullPublicKey);
this.deployParams({
classHash: OZ_ETH_ACCOUNT_CLASS_HASH,
salt,
constructorCalldata,
});
this._targetAddress = address;
return this;
}

public getTargetAddress(): string | undefined {
return this._targetAddress;
}

/** @inheritdoc */
initBuilder(tx: Transaction): void {
super.initBuilder(tx);
if (this._calls.length === 0) {
return;
}
const call = this._calls[0];
if (!utils.isUdcDeployCall(call)) {
return;
}
const parsed = utils.parseUdcDeployCall(call);
if (!parsed) {
throw new BuildTransactionError('Invalid UDC deploy calldata');
}
if (parsed.unique) {
throw new BuildTransactionError('UDC deploy with unique=true is not supported; BitGo requires unique=false');
}
this.deployParams({
classHash: parsed.classHash,
salt: parsed.salt,
constructorCalldata: parsed.constructorCalldata,
});
}

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
this.validateUdcDeploy();

const udcCall: StarknetCall = {
contractAddress: UDC_ADDRESS,
entrypoint: UDC_DEPLOY_ENTRYPOINT,
calldata: this.compileUdcDeployCalldata(
this._classHash,
this._salt as string,
this._constructorCalldata as string[]
),
};

this._calls = [udcCall];

return super.buildImplementation();
}

/** Calldata: [classHash, salt, unique=0, ctor_len, ...ctorCalldata] */
private compileUdcDeployCalldata(classHash: string, salt: string, constructorCalldata: string[]): string[] {
return [classHash, salt, '0x0', '0x' + BigInt(constructorCalldata.length).toString(16), ...constructorCalldata];
}

private validateUdcDeploy(): void {
if (!this._sender) {
throw new BuildTransactionError('Sender is required');
}
if (!utils.isValidAddress(this._sender)) {
throw new BuildTransactionError(`Invalid sender address: ${this._sender}`);
}
if (!this._salt || !this._constructorCalldata || this._constructorCalldata.length === 0) {
throw new BuildTransactionError(
'UDC deploy requires fromPublicKey() or deployParams({ classHash, salt, constructorCalldata })'
);
}
}
}
38 changes: 38 additions & 0 deletions modules/sdk-coin-starknet/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import {
L1_GAS_NAME,
L2_GAS_NAME,
L1_DATA_GAS_NAME,
UDC_ADDRESS,
UDC_DEPLOY_ENTRYPOINT,
} from './constants';
import {
StarknetTransactionData,
StarknetTransactionType,
StarknetCall,
ParsedTransferData,
ParsedUdcDeployData,
InvokeTransactionHashParams,
DeployAccountTransactionHashParams,
} from './iface';
Expand Down Expand Up @@ -192,6 +195,39 @@ export function parseTransferCall(call: StarknetCall): ParsedTransferData | unde
};
}

/** True if call targets UDC deployContract. */
export function isUdcDeployCall(call: StarknetCall | undefined): boolean {
if (!call) {
return false;
}
return (
call.entrypoint === UDC_DEPLOY_ENTRYPOINT &&
normalizeAddress(call.contractAddress) === normalizeAddress(UDC_ADDRESS) &&
call.calldata.length >= 4
);
}

/** Parse UDC deployContract calldata: [classHash, salt, unique, ctor_len, ...ctor]. */
export function parseUdcDeployCall(call: StarknetCall): ParsedUdcDeployData | undefined {
if (!isUdcDeployCall(call)) {
return undefined;
}
const classHash = call.calldata[0];
const salt = call.calldata[1];
const uniqueFelt = BigInt(call.calldata[2]);
const ctorLen = Number(BigInt(call.calldata[3]));
if (!Number.isFinite(ctorLen) || ctorLen < 0 || call.calldata.length < 4 + ctorLen) {
return undefined;
}
return {
classHash,
salt,
unique: uniqueFelt !== 0n,
constructorCalldata: call.calldata.slice(4, 4 + ctorLen),
udcAddress: call.contractAddress,
};
}

/**
* Generate a new secp256k1 key pair.
*/
Expand Down Expand Up @@ -389,6 +425,8 @@ export default {
normalizeAddress,
formatEthAccountSignature,
parseTransferCall,
isUdcDeployCall,
parseUdcDeployCall,
generateKeyPair,
validateRawTransaction,
encodeShortString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ describe('Starknet TransactionBuilderFactory', () => {
});
});

describe('getUdcDeployBuilder', () => {
it('should return a UDC deploy builder', () => {
const builder = new TransactionBuilderFactory(coinConfig).getUdcDeployBuilder();
should.exist(builder);
});
});

describe('from', () => {
it('should rebuild unsigned tx from raw hex', async () => {
const builder = await new TransactionBuilderFactory(coinConfig).from(rawTx.transfer.unsigned);
Expand Down
Loading
Loading