Skip to content

Commit

Permalink
Merge pull request #419 from 0xsequence/local
Browse files Browse the repository at this point in the history
Better support for local accounts
  • Loading branch information
Agusx1211 authored Sep 5, 2023
2 parents cab7ab4 + b5ee42b commit 020af3b
Show file tree
Hide file tree
Showing 15 changed files with 2,081 additions and 551 deletions.
26 changes: 19 additions & 7 deletions packages/account/src/account.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { commons, universal } from '@0xsequence/core'
import { migrator, defaults, version } from '@0xsequence/migration'
import { NetworkConfig } from '@0xsequence/network'
import { ChainId, NetworkConfig } from '@0xsequence/network'
import { FeeOption, FeeQuote, isRelayer, Relayer, RpcRelayer } from '@0xsequence/relayer'
import { tracker } from '@0xsequence/sessions'
import { Orchestrator } from '@0xsequence/signhub'
import { encodeTypedDataDigest, getDefaultConnectionInfo } from '@0xsequence/utils'
import { Wallet } from '@0xsequence/wallet'
import { ethers, TypedDataDomain, TypedDataField } from 'ethers'
import { AccountSigner, AccountSignerOptions } from './signer'

export type AccountStatus = {
original: {
Expand Down Expand Up @@ -106,6 +107,10 @@ export class Account {
this.migrator = new migrator.Migrator(options.tracker, this.migrations, this.contexts)
}

getSigner(chainId: ChainId, options?: AccountSignerOptions): AccountSigner {
return new AccountSigner(this, chainId, options)
}

static async new(options: {
config: commons.config.SimpleConfig
tracker: tracker.ConfigTracker & migrator.PresignedMigrationTracker
Expand Down Expand Up @@ -163,7 +168,7 @@ export class Account {
return found
}

provider(chainId: ethers.BigNumberish): ethers.providers.Provider {
providerFor(chainId: ethers.BigNumberish): ethers.providers.Provider {
const found = this.network(chainId)
if (!found.provider && !found.rpcUrl) throw new Error(`Provider not found for chainId ${chainId}`)
return (
Expand All @@ -180,7 +185,7 @@ export class Account {

// TODO: Networks should be able to provide a reader directly
// and we should default to the on-chain reader
return new commons.reader.OnChainReader(this.provider(chainId))
return new commons.reader.OnChainReader(this.providerFor(chainId))
}

relayer(chainId: ethers.BigNumberish): Relayer {
Expand Down Expand Up @@ -608,13 +613,16 @@ export class Account {
async signTransactions(
txs: commons.transaction.Transactionish,
chainId: ethers.BigNumberish,
pstatus?: AccountStatus
pstatus?: AccountStatus,
options?: {
nonceSpace?: ethers.BigNumberish
}
): Promise<commons.transaction.SignedTransactionBundle> {
const status = pstatus || (await this.status(chainId))
this.mustBeFullyMigrated(status)

const wallet = this.walletForStatus(chainId, status)
const signed = await wallet.signTransactions(txs)
const signed = await wallet.signTransactions(txs, options?.nonceSpace && { space: options?.nonceSpace })

return {
...signed,
Expand Down Expand Up @@ -788,11 +796,15 @@ export class Account {
chainId: ethers.BigNumberish,
quote?: FeeQuote,
skipPreDecorate: boolean = false,
callback?: (bundle: commons.transaction.IntendedTransactionBundle) => void
callback?: (bundle: commons.transaction.IntendedTransactionBundle) => void,
options?: {
nonceSpace?: ethers.BigNumberish
}
): Promise<ethers.providers.TransactionResponse> {
const status = await this.status(chainId)

const predecorated = skipPreDecorate ? txs : await this.predecorateTransactions(txs, status, chainId)
const signed = await this.signTransactions(predecorated, chainId)
const signed = await this.signTransactions(predecorated, chainId, undefined, options)
// TODO: is it safe to pass status again here?
return this.sendSignedTransactions(signed, chainId, quote, undefined, callback)
}
Expand Down
221 changes: 221 additions & 0 deletions packages/account/src/signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { ChainId } from "@0xsequence/network"
import { Account } from "./account"
import { ethers } from "ethers"
import { commons } from "@0xsequence/core"
import { FeeOption, proto } from "@0xsequence/relayer"
import { isDeferrable } from "./utils"

export type AccountSignerOptions = {
nonceSpace?: ethers.BigNumberish,
cantValidateBehavior?: 'ignore' | 'eip6492' | 'throw',
stubSignatureOverrides?: Map<string, string>,
selectFee?: (
txs: ethers.utils.Deferrable<ethers.providers.TransactionRequest> | commons.transaction.Transactionish,
options: FeeOption[]
) => Promise<FeeOption | undefined>
}

function encodeGasRefundTransaction(option?: FeeOption) {
if (!option) return []

const value = ethers.BigNumber.from(option.value)

switch (option.token.type) {
case proto.FeeTokenType.UNKNOWN:
return [{
delegateCall: false,
revertOnError: true,
gasLimit: option.gasLimit,
to: option.to,
value: value.toHexString(),
data: []
}]

case proto.FeeTokenType.ERC20_TOKEN:
if (!option.token.contractAddress) {
throw new Error(`No contract address for ERC-20 fee option`)
}

return [{
delegateCall: false,
revertOnError: true,
gasLimit: option.gasLimit,
to: option.token.contractAddress,
value: 0,
data: new ethers.utils.Interface([{
"constant": false,
"inputs": [
{"type": "address"},
{"type": "uint256"}
],
"name": "transfer",
"outputs": [],
"type": "function"
}]).encodeFunctionData('transfer', [option.to, value.toHexString()])
}]

default:
throw new Error(`Unhandled fee token type ${option.token.type}`)
}
}

export class AccountSigner implements ethers.Signer {
public readonly _isSigner = true

constructor (
public account: Account,
public chainId: ChainId,
public readonly options?: AccountSignerOptions
) {}

get provider() {
return this.account.providerFor(this.chainId)
}

async getAddress(): Promise<string> {
return this.account.address
}

signMessage(
message: string | ethers.utils.Bytes
): Promise<string> {
return this.account.signMessage(
message,
this.chainId,
this.options?.cantValidateBehavior ?? 'throw'
)
}

private async defaultSelectFee(
_txs: ethers.utils.Deferrable<ethers.providers.TransactionRequest> | commons.transaction.Transactionish,
options: FeeOption[]
): Promise<FeeOption | undefined> {
// If no options, return undefined
if (options.length === 0) return undefined

// If there are multiple options, try them one by one
// until we find one that satisfies the balance requirement
const balanceOfAbi = [{
"constant": true,
"inputs": [{"type": "address"}],
"name": "balanceOf",
"outputs": [{"type": "uint256"}],
"type": "function"
}]

for (const option of options) {
if (option.token.type === proto.FeeTokenType.UNKNOWN) {
// Native token
const balance = await this.getBalance()
if (balance.gte(ethers.BigNumber.from(option.value))) {
return option
}
} else if (option.token.contractAddress && option.token.type === proto.FeeTokenType.ERC20_TOKEN) {
// ERC20 token
const token = new ethers.Contract(option.token.contractAddress, balanceOfAbi, this.provider)
const balance = await token.balanceOf(this.account.address)
if (balance.gte(ethers.BigNumber.from(option.value))) {
return option
}
} else {
// Unsupported token type
}
}

throw new Error("No fee option available - not enough balance")
}

async sendTransaction(
txsPromise: ethers.utils.Deferrable<ethers.providers.TransactionRequest> | commons.transaction.Transactionish
): Promise<ethers.providers.TransactionResponse> {
const txs = isDeferrable(txsPromise) ? (
await ethers.utils.resolveProperties(txsPromise as ethers.utils.Deferrable<ethers.providers.TransactionRequest>))
: txsPromise

const prepare = await this.account.prepareTransactions({
txs,
chainId: this.chainId,
stubSignatureOverrides: this.options?.stubSignatureOverrides ?? new Map()
})

const selectMethod = this.options?.selectFee ?? this.defaultSelectFee.bind(this)
const feeOption = await selectMethod(txs, prepare.feeOptions)

const finalTransactions = [
...prepare.transactions,
...encodeGasRefundTransaction(feeOption)
]

return this.account.sendTransaction(
finalTransactions,
this.chainId,
prepare.feeQuote,
undefined,
undefined,
this.options?.nonceSpace ? {
nonceSpace: this.options.nonceSpace
} : undefined
)
}

getBalance(blockTag?: ethers.providers.BlockTag | undefined): Promise<ethers.BigNumber> {
return this.provider.getBalance(this.account.address, blockTag)
}

call(
transaction: ethers.utils.Deferrable<ethers.providers.TransactionRequest>,
blockTag?: ethers.providers.BlockTag | undefined
): Promise<string> {
return this.provider.call(transaction, blockTag)
}

async resolveName(name: string): Promise<string> {
const res = await this.provider.resolveName(name)
if (!res) throw new Error(`Could not resolve name ${name}`)
return res
}

connect(_provider: ethers.providers.Provider): ethers.Signer {
throw new Error("Method not implemented.")
}

signTransaction(
transaction: ethers.utils.Deferrable<ethers.providers.TransactionRequest>
): Promise<string> {
throw new Error("Method not implemented.")
}

getTransactionCount(blockTag?: ethers.providers.BlockTag | undefined): Promise<number> {
throw new Error("Method not implemented.")
}

estimateGas(
transaction: ethers.utils.Deferrable<ethers.providers.TransactionRequest>
): Promise<ethers.BigNumber> {
throw new Error("Method not implemented.")
}

getChainId(): Promise<number> {
return Promise.resolve(ethers.BigNumber.from(this.chainId).toNumber())
}

getGasPrice(): Promise<ethers.BigNumber> {
throw new Error("Method not implemented.")
}

getFeeData(): Promise<ethers.providers.FeeData> {
throw new Error("Method not implemented.")
}

checkTransaction(transaction: ethers.utils.Deferrable<ethers.providers.TransactionRequest>): ethers.utils.Deferrable<ethers.providers.TransactionRequest> {
throw new Error("Method not implemented.")
}

populateTransaction(transaction: ethers.utils.Deferrable<ethers.providers.TransactionRequest>): Promise<ethers.providers.TransactionRequest> {
throw new Error("Method not implemented.")
}

_checkProvider(operation?: string | undefined): void {
throw new Error("Method not implemented.")
}
}
14 changes: 14 additions & 0 deletions packages/account/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ethers } from "ethers"

function isPromise(value: any): value is Promise<any> {
return !!value && typeof value.then === 'function'
}

export function isDeferrable<T>(value: any): value is ethers.utils.Deferrable<T> {
// The value is deferrable if any of the properties is a Promises
if (typeof(value) === "object") {
return Object.keys(value).some((key) => isPromise(value[key]))
}

return false
}
8 changes: 4 additions & 4 deletions packages/account/tests/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,25 +1117,25 @@ describe('Account', () => {
})

let nowCalls = 0
function now(): number {
export function now(): number {
if (deterministic) {
return Date.parse('2023-02-14T00:00:00.000Z') + 1000 * nowCalls++
} else {
return Date.now()
}
}

function randomWallet(entropy: number | string): ethers.Wallet {
export function randomWallet(entropy: number | string): ethers.Wallet {
return new ethers.Wallet(randomBytes(32, entropy))
}

function randomFraction(entropy: number | string): number {
export function randomFraction(entropy: number | string): number {
const bytes = randomBytes(7, entropy)
bytes[0] &= 0x1f
return bytes.reduce((sum, byte) => 256 * sum + byte) / Number.MAX_SAFE_INTEGER
}

function randomBytes(length: number, entropy: number | string): Uint8Array {
export function randomBytes(length: number, entropy: number | string): Uint8Array {
if (deterministic) {
let bytes = ''
while (bytes.length < 2 * length) {
Expand Down
Loading

0 comments on commit 020af3b

Please sign in to comment.