Skip to content

Commit

Permalink
feat: draft SNIP-9 implementation (#1111)
Browse files Browse the repository at this point in the history
  • Loading branch information
kfastov authored Aug 26, 2024
1 parent bdad9a5 commit 2844069
Show file tree
Hide file tree
Showing 12 changed files with 38,418 additions and 0 deletions.
1 change: 1 addition & 0 deletions __mocks__/cairo/account/accountArgent040.casm

Large diffs are not rendered by default.

37,885 changes: 37,885 additions & 0 deletions __mocks__/cairo/account/accountArgent040.json

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions __tests__/account.outsideExecution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { getStarkKey, utils } from '@scure/starknet';
import { Provider, Account, cairo } from '../src';
import { SNIP9_V1_INTERFACE_ID } from '../src/types/outsideExecution';
import { OutsideExecution } from '../src/utils/outsideExecution';
import { randomAddress } from '../src/utils/stark';
import {
compiledArgentAccount,
compiledArgentAccountCasm,
getTestAccount,
getTestProvider,
} from './config/fixtures';

const { uint256 } = cairo;

describe('Account and OutsideExecution', () => {
const devnetERC20Address = '0x49D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7';
const provider = new Provider(getTestProvider());
const sender = getTestAccount(provider);
let target: Account;
const targetPK = utils.randomPrivateKey();
const targetOwner = getStarkKey(targetPK);
// For ERC20 transfer outside call
const recipient = randomAddress();
const transferAmount = 100;

beforeAll(async () => {
// Deploy the target account:
const response = await sender.declareAndDeploy(
{
contract: compiledArgentAccount,
casm: compiledArgentAccountCasm,
constructorCalldata: [0, targetOwner, 1], // signer = targetOwner, guardian = None
},
{ maxFee: 1e18 }
);
const targetAddress = response.deploy.contract_address;
target = new Account(provider, targetAddress, targetPK);

// Transfer some tokens to the target account
const transferCall = {
contractAddress: devnetERC20Address,
entrypoint: 'transfer',
calldata: {
recipient: targetAddress,
amount: uint256(transferAmount),
},
};

const { transaction_hash } = await sender.execute(transferCall, undefined, { maxFee: 1e18 });
await provider.waitForTransaction(transaction_hash);
});

it('target account should support SNIP-9', async () => {
const res = await sender.callContract({
contractAddress: target.address,
entrypoint: 'supports_interface',
calldata: [SNIP9_V1_INTERFACE_ID],
});

expect(res[0]).toBe('0x1');
});

it('should execute OutsideExecution flow', async () => {
// Create calls to ERC20 contract: transfer 100 tokens to random address
const calls = [
{
contractAddress: devnetERC20Address,
entrypoint: 'transfer',
calldata: {
recipient,
amount: uint256(transferAmount),
},
},
];

// Prepare time bounds
const now_seconds = Math.floor(Date.now() / 1000);
const hour_ago = (now_seconds - 3600).toString();
const hour_later = (now_seconds + 3600).toString();

// Create OutsideExecution object
const options = {
caller: sender.address,
nonce: await sender.getNonce(),
execute_after: hour_ago,
execute_before: hour_later,
};

const outsideExecution = new OutsideExecution(calls, options);

// Get supported SNIP-9 version of target account
const snip9Version = await target.getSnip9Version();
expect(snip9Version).toBeDefined();

// Sign the outside execution
const data = outsideExecution.getTypedData(await sender.getChainId(), snip9Version!);
const signature = await target.signMessage(data);

// Execute OutsideExecution from sender account
const response = await sender.executeFromOutside(
outsideExecution,
signature,
target.address,
{},
snip9Version
);
await provider.waitForTransaction(response.transaction_hash);

// Check recipient's balance
const balanceRes = await provider.callContract({
contractAddress: devnetERC20Address,
entrypoint: 'balanceOf',
calldata: {
user: recipient,
},
});
expect(balanceRes[0]).toBe('0x64'); // 100 tokens
});
});
2 changes: 2 additions & 0 deletions __tests__/config/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const compiledHelloSierraCasm = readContractSierraCasm('cairo/helloSierra
export const compiledComplexSierra = readContractSierra('cairo/complexInput/complexInput');
export const compiledC1Account = readContractSierra('cairo/account/accountOZ080');
export const compiledC1AccountCasm = readContractSierraCasm('cairo/account/accountOZ080');
export const compiledArgentAccount = readContractSierra('cairo/account/accountArgent040');
export const compiledArgentAccountCasm = readContractSierraCasm('cairo/account/accountArgent040');
export const compiledC1v2 = readContractSierra('cairo/helloCairo2/compiled');
export const compiledC1v2Casm = readContractSierraCasm('cairo/helloCairo2/compiled');
export const compiledC210 = readContractSierra('cairo/cairo210/cairo210.sierra');
Expand Down
89 changes: 89 additions & 0 deletions src/account/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,23 @@ import {
UniversalDetails,
} from '../types';
import { ETransactionVersion, ETransactionVersion3, ResourceBounds } from '../types/api';
import {
EOutsideExecutionVersion,
SNIP9_V1_INTERFACE_ID,
SNIP9_V2_INTERFACE_ID,
} from '../types/outsideExecution';
import { OutsideExecution, buildExecuteFromOutsideCallData } from '../utils/outsideExecution';
import { CallData } from '../utils/calldata';
import { extractContractHashes, isSierra } from '../utils/contract';
import { parseUDCEvent } from '../utils/events';
import { calculateContractAddressFromHash } from '../utils/hash';
import { toBigInt, toCairoBool } from '../utils/num';
import { parseContract } from '../utils/provider';
import { isString } from '../utils/shortString';
import { supportsInterface } from '../utils/src5';
import {
estimateFeeToBounds,
randomAddress,
reduceV2,
toFeeVersion,
toTransactionVersion,
Expand Down Expand Up @@ -578,6 +586,87 @@ export class Account extends Provider implements AccountInterface {
);
}

public async getSnip9Version(): Promise<EOutsideExecutionVersion> {
// Check for support of the SNIP-9 version 2 interface

const supportsSnip9V2 = await supportsInterface(this, this.address, SNIP9_V2_INTERFACE_ID);

if (supportsSnip9V2) {
return EOutsideExecutionVersion.V2;
}

// Check for support of the SNIP-9 version 1 interface
const supportsSnip9V1 = await supportsInterface(this, this.address, SNIP9_V1_INTERFACE_ID);

if (supportsSnip9V1) {
return EOutsideExecutionVersion.V1;
}

// Account does not support either version 2 or version 1
return EOutsideExecutionVersion.UNSUPPORTED;
}

public async isValidSnip9Nonce(nonce: BigNumberish): Promise<boolean> {
try {
const call: Call = {
contractAddress: this.address,
entrypoint: 'is_valid_outside_execution_nonce',
calldata: CallData.compile({ nonce }),
};

const resp = await this.callContract(call);

// Transforming the result into a boolean value
return BigInt(resp[0]) !== 0n;
} catch (error) {
throw new Error(`Failed to check if nonce is valid: ${error}`);
}
}

public async executeFromOutside(
outsideExecution: OutsideExecution,
signature: Signature,
targetAddress: string,
opts: UniversalDetails,
version?: EOutsideExecutionVersion | undefined
): Promise<{ transaction_hash: string }> {
// if the version is not specified, try to determine the latest supported version
const supportedVersion =
version ?? (await new Account(this, targetAddress, this.signer).getSnip9Version());
// choose the correct entrypoint
let entrypoint: string;
if (supportedVersion === EOutsideExecutionVersion.V1) {
entrypoint = 'execute_from_outside';
} else if (supportedVersion === EOutsideExecutionVersion.V2) {
entrypoint = 'execute_from_outside_v2';
} else {
throw new Error('Unsupported OutsideExecution version');
}

// prepare the call
const call = {
contractAddress: targetAddress,
entrypoint,
calldata: buildExecuteFromOutsideCallData(outsideExecution, signature),
};
// execute the call
return this.execute(call, opts);
}

public async getSnip9Nonce(account: Account): Promise<string> {
// create random nonce (in felt range)
const nonce = randomAddress();
// check if nonce is already used
const isValidNonce = await account.isValidSnip9Nonce(nonce);
if (!isValidNonce) {
// if it is, try again
return this.getSnip9Nonce(account);
}
// if not, return it
return nonce;
// TODO process errors
}

/*
* Support methods
*/
Expand Down
24 changes: 24 additions & 0 deletions src/account/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
SimulateTransactionResponse,
TypedData,
UniversalDeployerContractPayload,
UniversalDetails,
} from '../types';
import { EOutsideExecutionVersion } from '../types/outsideExecution';
import { OutsideExecution } from '../utils/outsideExecution';

export abstract class AccountInterface extends ProviderInterface {
public abstract address: string;
Expand Down Expand Up @@ -362,6 +365,27 @@ export abstract class AccountInterface extends ProviderInterface {
*/
public abstract hashMessage(typedData: TypedData): Promise<string>;

/**
* Get the supported version of the outside execution for the account
* @returns the supported version of the outside execution
*/
public abstract getSnip9Version(): Promise<EOutsideExecutionVersion | undefined>;

/**
* Execute an outside execution on the account
* @param outsideExecution - the outside execution object
* @param signature - the signature for the outside execution
* @param targetAddress - the address of an account on which the outside execution will be executed
* @param version - the version of the outside execution standard
*/
public abstract executeFromOutside(
outsideExecution: OutsideExecution,
signature: Signature,
targetAddress: string,
opts: UniversalDetails,
version?: EOutsideExecutionVersion
): Promise<InvokeFunctionResponse>;

/**
* Gets the nonce of the account with respect to a specific block
*
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export * as starknetId from './utils/starknetId';
export * as provider from './utils/provider';
export * as selector from './utils/hash/selector';
export * as events from './utils/events';
export * as outsideExecution from './utils/outsideExecution';
export * from './utils/responseParser';
export * from './utils/cairoDataTypes/uint256';
export * from './utils/cairoDataTypes/uint512';
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export * from './signer';
export * from './typedData';
export * from './cairoEnum';
export * from './transactionReceipt';
export * from './outsideExecution';

export * as RPC from './api';
69 changes: 69 additions & 0 deletions src/types/outsideExecution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { BigNumberish, RawArgs } from './lib';

export interface OutsideExecutionOptions {
caller: string;
nonce: BigNumberish;
execute_after: BigNumberish;
execute_before: BigNumberish;
}

export interface OutsideCall {
to: string;
selector: BigNumberish;
calldata: RawArgs;
}

export const SNIP9_V1_INTERFACE_ID =
'0x68cfd18b92d1907b8ba3cc324900277f5a3622099431ea85dd8089255e4181';
export const SNIP9_V2_INTERFACE_ID =
'0x1d1144bb2138366ff28d8e9ab57456b1d332ac42196230c3a602003c89872';

export const OutsideExecutionTypesV1 = {
StarkNetDomain: [
{ name: 'name', type: 'felt' },
{ name: 'version', type: 'felt' },
{ name: 'chainId', type: 'felt' },
],
OutsideExecution: [
{ name: 'caller', type: 'felt' },
{ name: 'nonce', type: 'felt' },
{ name: 'execute_after', type: 'felt' },
{ name: 'execute_before', type: 'felt' },
{ name: 'calls_len', type: 'felt' },
{ name: 'calls', type: 'OutsideCall*' },
],
OutsideCall: [
{ name: 'to', type: 'felt' },
{ name: 'selector', type: 'felt' },
{ name: 'calldata_len', type: 'felt' },
{ name: 'calldata', type: 'felt*' },
],
};

export const OutsideExecutionTypesV2 = {
StarknetDomain: [
// SNIP-12 revision 1 is used, so should be "StarknetDomain", not "StarkNetDomain"
{ name: 'name', type: 'shortstring' },
{ name: 'version', type: 'shortstring' }, // set to 2 in v2
{ name: 'chainId', type: 'shortstring' },
{ name: 'revision', type: 'shortstring' },
],
OutsideExecution: [
{ name: 'Caller', type: 'ContractAddress' },
{ name: 'Nonce', type: 'felt' },
{ name: 'Execute After', type: 'u128' },
{ name: 'Execute Before', type: 'u128' },
{ name: 'Calls', type: 'Call*' },
],
Call: [
{ name: 'To', type: 'ContractAddress' },
{ name: 'Selector', type: 'selector' },
{ name: 'Calldata', type: 'felt*' },
],
};

export enum EOutsideExecutionVersion {
UNSUPPORTED = '0',
V1 = '1',
V2 = '2',
}
Loading

0 comments on commit 2844069

Please sign in to comment.