a multi-provider and tool for interacting with deployed contracts (#763)
* wip: a multi-provider and tool for seeing deployed contracts * refactor: break things out a bit * feature: loadJson * feature: OpticsContext extends MultiProvider * feature: hardcode mainnet deploy * bugs: empty ethHelper and domain handling * feature: resolveDomain * feature: resolve token info * feature: draft send implementation * feature: imporve sendCoins.ts * feature: OpticsMessage class * feature: BridgeMessage extends OpticsMessage * refactor: move all optics-specific behavior into folder * refactor: rearrange contracts and imporve exports * feature: parseMessage * refactor: simplify and improve message classes * feature: re-implement status call on OpticsMessage * feature: resolve asset on BridgeMessage * refactor: BridgeMessage is generic over action type * bug: properly export new BridgeMessage variants * feature: add a metamask file * feature: dev and staging + bug: improper signer registration * refactor: abstract out reconnectionbuddies-main-deployment
parent
73e80a063c
commit
e630652fc9
@ -0,0 +1,5 @@ |
||||
{ |
||||
"tabWidth": 2, |
||||
"singleQuote": true, |
||||
"trailingComma": "all" |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,14 @@ |
||||
{ |
||||
"name": "optics-provider", |
||||
"version": "0.0.0", |
||||
"description": "multi-provider for Optics", |
||||
"main": "src/index.ts", |
||||
"scripts": { |
||||
"test": "echo \"Error: no test specified\" && exit 1" |
||||
}, |
||||
"author": "Celo Labs Inc.", |
||||
"license": "MIT OR Apache-2.0", |
||||
"dependencies": { |
||||
"ethers": "^5.4.6" |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
import { ethers } from 'ethers'; |
||||
import fs from 'fs'; |
||||
|
||||
export abstract class Contracts { |
||||
readonly args: any; |
||||
|
||||
constructor(...args: any) { |
||||
this.args = args; |
||||
} |
||||
|
||||
abstract toObject(): any; |
||||
|
||||
abstract connect(signer: ethers.Signer): void; |
||||
|
||||
toJson(): string { |
||||
return JSON.stringify(this.toObject()); |
||||
} |
||||
|
||||
toJsonPretty(): string { |
||||
return JSON.stringify(this.toObject(), null, 2); |
||||
} |
||||
|
||||
saveJson(filepath: string) { |
||||
fs.writeFileSync(filepath, this.toJsonPretty()); |
||||
} |
||||
} |
@ -0,0 +1,4 @@ |
||||
export interface Domain { |
||||
domain: number; |
||||
name: string; |
||||
} |
@ -0,0 +1,15 @@ |
||||
export { MultiProvider } from './provider'; |
||||
|
||||
export { mainnet, dev, staging, OpticsContext } from './optics'; |
||||
|
||||
// intended usage
|
||||
// import {mainnet} from 'optics-provider';
|
||||
|
||||
// mainnet.registerRpcProvider('celo', 'https://forno.celo.org');
|
||||
// mainnet.registerRpcProvider('polygon', '...');
|
||||
// mainnet.registerRpcProvider('ethereum', '...');
|
||||
// mainnet.registerSigner('celo', ...);
|
||||
// mainnet.registerSigner('polygon', ...);
|
||||
// mainnet.registerSigner('ethereum', ...);
|
||||
|
||||
// mainnet.doWhatever
|
@ -0,0 +1,45 @@ |
||||
export type MetamaskNetwork = { |
||||
chainId: string; |
||||
chainName: string; |
||||
nativeCurrency: { name: string; symbol: string; decimals: number }; |
||||
rpcUrls: string[]; |
||||
blockExplorerUrls: string[]; |
||||
iconUrls: string[]; |
||||
}; |
||||
|
||||
export const CELO_PARAMS: MetamaskNetwork = { |
||||
chainId: '0xa4ec', |
||||
chainName: 'Celo', |
||||
nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, |
||||
rpcUrls: ['https://forno.celo.org'], |
||||
blockExplorerUrls: ['https://explorer.celo.org/'], |
||||
iconUrls: ['future'], |
||||
}; |
||||
|
||||
export const ALFAJORES_PARAMS: MetamaskNetwork = { |
||||
chainId: '0xaef3', |
||||
chainName: 'Alfajores Testnet', |
||||
nativeCurrency: { name: 'Alfajores Celo', symbol: 'A-CELO', decimals: 18 }, |
||||
rpcUrls: ['https://alfajores-forno.celo-testnet.org'], |
||||
blockExplorerUrls: ['https://alfajores-blockscout.celo-testnet.org/'], |
||||
iconUrls: ['future'], |
||||
}; |
||||
|
||||
export const BAKLAVA_PARAMS: MetamaskNetwork = { |
||||
chainId: '0xf370', |
||||
chainName: 'Baklava Testnet', |
||||
nativeCurrency: { name: 'Baklava Celo', symbol: 'B-CELO', decimals: 18 }, |
||||
rpcUrls: ['https://baklava-forno.celo-testnet.org'], |
||||
blockExplorerUrls: ['https://baklava-blockscout.celo-testnet.org/'], |
||||
iconUrls: ['future'], |
||||
}; |
||||
|
||||
export async function connect(params: MetamaskNetwork) { |
||||
const w = window as any; |
||||
if (w.ethereum) { |
||||
await w.ethereum.request({ |
||||
method: 'wallet_addEthereumChain', |
||||
params: [params], |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,234 @@ |
||||
import { BigNumberish, ContractTransaction, ethers } from 'ethers'; |
||||
import { MultiProvider } from '..'; |
||||
import { ERC20, ERC20__factory } from '../../../typechain/optics-xapps'; |
||||
import { BridgeContracts } from './contracts/BridgeContracts'; |
||||
import { CoreContracts } from './contracts/CoreContracts'; |
||||
import { ResolvedTokenInfo, TokenIdentifier } from './tokens'; |
||||
import { canonizeId } from '../utils'; |
||||
import { |
||||
devDomains, |
||||
mainnetDomains, |
||||
OpticsDomain, |
||||
stagingDomains, |
||||
} from './domains'; |
||||
import { Replica } from '../../../typechain/optics-core'; |
||||
|
||||
type Address = string; |
||||
|
||||
export class OpticsContext extends MultiProvider { |
||||
private cores: Map<number, CoreContracts>; |
||||
private bridges: Map<number, BridgeContracts>; |
||||
|
||||
constructor( |
||||
domains: OpticsDomain[], |
||||
cores: CoreContracts[], |
||||
bridges: BridgeContracts[], |
||||
) { |
||||
super(); |
||||
domains.forEach((domain) => this.registerDomain(domain)); |
||||
this.cores = new Map(); |
||||
this.bridges = new Map(); |
||||
|
||||
cores.forEach((core) => { |
||||
this.cores.set(core.domain, core); |
||||
}); |
||||
bridges.forEach((bridge) => { |
||||
this.bridges.set(bridge.domain, bridge); |
||||
}); |
||||
} |
||||
|
||||
static fromDomains(domains: OpticsDomain[]): OpticsContext { |
||||
const cores = domains.map((domain) => CoreContracts.fromObject(domain)); |
||||
const bridges = domains.map((domain) => BridgeContracts.fromObject(domain)); |
||||
return new OpticsContext(domains, cores, bridges); |
||||
} |
||||
|
||||
private reconnect(domain: number) { |
||||
const connection = this.getConnection(domain); |
||||
if (!connection) { |
||||
throw new Error('Reconnect failed: no connection'); |
||||
} |
||||
// re-register contracts
|
||||
const core = this.cores.get(domain); |
||||
if (core) { |
||||
core.connect(connection); |
||||
} |
||||
const bridge = this.bridges.get(domain); |
||||
if (bridge) { |
||||
bridge.connect(connection); |
||||
} |
||||
} |
||||
|
||||
registerProvider( |
||||
nameOrDomain: string | number, |
||||
provider: ethers.providers.Provider, |
||||
) { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
super.registerProvider(domain, provider); |
||||
this.reconnect(domain); |
||||
} |
||||
|
||||
registerSigner(nameOrDomain: string | number, signer: ethers.Signer) { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
super.registerSigner(domain, signer); |
||||
this.reconnect(domain); |
||||
} |
||||
|
||||
unregisterSigner(nameOrDomain: string | number): void { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
super.unregisterSigner(domain); |
||||
this.reconnect(domain); |
||||
} |
||||
|
||||
getCore(nameOrDomain: string | number): CoreContracts | undefined { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
return this.cores.get(domain); |
||||
} |
||||
|
||||
getBridge(nameOrDomain: string | number): BridgeContracts | undefined { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
return this.bridges.get(domain); |
||||
} |
||||
|
||||
// gets the replica of Home on Remote
|
||||
getReplicaFor( |
||||
home: string | number, |
||||
remote: string | number, |
||||
): Replica | undefined { |
||||
return this.getCore(remote)?.replicas.get(this.resolveDomain(home)) |
||||
?.contract; |
||||
} |
||||
|
||||
// resolve the local repr of a token on its domain
|
||||
async resolveTokenRepresentation( |
||||
nameOrDomain: string | number, |
||||
token: TokenIdentifier, |
||||
): Promise<ERC20 | undefined> { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
const bridge = this.getBridge(domain); |
||||
|
||||
const tokenDomain = this.resolveDomain(token.domain); |
||||
const tokenId = canonizeId(token.id); |
||||
|
||||
const address = await bridge?.bridgeRouter[ |
||||
'getLocalAddress(uint32,bytes32)' |
||||
](tokenDomain, tokenId); |
||||
|
||||
if (!address) { |
||||
return; |
||||
} |
||||
|
||||
let contract = new ERC20__factory().attach(address); |
||||
|
||||
const connection = this.getConnection(domain); |
||||
if (connection) { |
||||
contract = contract.connect(connection); |
||||
} |
||||
return contract; |
||||
} |
||||
|
||||
// resolve all token representations
|
||||
async tokenRepresentations( |
||||
token: TokenIdentifier, |
||||
): Promise<ResolvedTokenInfo> { |
||||
const tokens: Map<number, ERC20> = new Map(); |
||||
|
||||
await Promise.all( |
||||
this.domainNumbers.map(async (domain) => { |
||||
let tok = await this.resolveTokenRepresentation(domain, token); |
||||
if (tok) { |
||||
tokens.set(domain, tok); |
||||
} |
||||
}), |
||||
); |
||||
|
||||
return { |
||||
domain: this.resolveDomain(token.domain), |
||||
id: token.id, |
||||
tokens, |
||||
}; |
||||
} |
||||
|
||||
async resolveCanonicalToken( |
||||
nameOrDomain: string | number, |
||||
representation: Address, |
||||
): Promise<TokenIdentifier | undefined> { |
||||
const bridge = this.getBridge(nameOrDomain); |
||||
if (!bridge) { |
||||
throw new Error(`Bridge not available on ${nameOrDomain}`); |
||||
} |
||||
|
||||
const token = await bridge.bridgeRouter.getCanonicalAddress(representation); |
||||
if (token[0] === 0) { |
||||
return; |
||||
} |
||||
return { |
||||
domain: token[0], |
||||
id: token[1], |
||||
}; |
||||
} |
||||
|
||||
// send tokens from domain to domain
|
||||
async send( |
||||
from: string | number, |
||||
to: string | number, |
||||
token: TokenIdentifier, |
||||
amount: BigNumberish, |
||||
recipient: Address, |
||||
): Promise<ContractTransaction> { |
||||
const fromBridge = this.getBridge(from); |
||||
if (!fromBridge) { |
||||
throw new Error(`Bridge not available on ${from}`); |
||||
} |
||||
|
||||
const fromToken = await this.resolveTokenRepresentation(from, token); |
||||
if (!fromToken) { |
||||
throw new Error(`Token not available on ${from}`); |
||||
} |
||||
|
||||
const bridgeAddress = fromBridge?.bridgeRouter.address; |
||||
if (!bridgeAddress) { |
||||
throw new Error(`No bridge for ${from}`); |
||||
} |
||||
|
||||
const sender = this.getSigner(from); |
||||
if (!sender) { |
||||
throw new Error(`No signer for ${from}`); |
||||
} |
||||
const senderAddress = await sender.getAddress(); |
||||
|
||||
const approved = await fromToken.allowance(senderAddress, bridgeAddress); |
||||
|
||||
// Approve if necessary
|
||||
if (approved.lt(amount)) { |
||||
await fromToken.approve(bridgeAddress, amount); |
||||
} |
||||
|
||||
return fromBridge.bridgeRouter.send( |
||||
fromToken.address, |
||||
amount, |
||||
to, |
||||
recipient, |
||||
); |
||||
} |
||||
|
||||
async sendNative( |
||||
from: string | number, |
||||
to: string | number, |
||||
amount: BigNumberish, |
||||
recipient: Address, |
||||
): Promise<ContractTransaction> { |
||||
const ethHelper = this.getBridge(from)?.ethHelper; |
||||
if (!ethHelper) { |
||||
throw new Error(`No ethHelper for ${from}`); |
||||
} |
||||
|
||||
const toDomain = this.resolveDomain(to); |
||||
|
||||
return ethHelper.sendToEVMLike(toDomain, recipient, { value: amount }); |
||||
} |
||||
} |
||||
|
||||
export const mainnet = OpticsContext.fromDomains(mainnetDomains); |
||||
export const dev = OpticsContext.fromDomains(devDomains); |
||||
export const staging = OpticsContext.fromDomains(stagingDomains); |
@ -0,0 +1,67 @@ |
||||
import fs from 'fs'; |
||||
import { ethers } from 'ethers'; |
||||
import { |
||||
BridgeRouter, |
||||
BridgeRouter__factory, |
||||
ETHHelper, |
||||
ETHHelper__factory, |
||||
} from '../../../../typechain/optics-xapps'; |
||||
import { Contracts } from '../../contracts'; |
||||
|
||||
type Address = string; |
||||
|
||||
export class BridgeContracts extends Contracts { |
||||
domain: number; |
||||
bridgeRouter: BridgeRouter; |
||||
ethHelper?: ETHHelper; |
||||
|
||||
constructor( |
||||
domain: number, |
||||
br: Address, |
||||
ethHelper?: Address, |
||||
signer?: ethers.Signer, |
||||
) { |
||||
super(domain, br, ethHelper, signer); |
||||
this.domain = domain; |
||||
this.bridgeRouter = new BridgeRouter__factory(signer).attach(br); |
||||
if (ethHelper) { |
||||
this.ethHelper = new ETHHelper__factory(signer).attach(ethHelper); |
||||
} |
||||
} |
||||
|
||||
connect(providerOrSigner: ethers.providers.Provider | ethers.Signer): void { |
||||
this.bridgeRouter = this.bridgeRouter.connect(providerOrSigner); |
||||
if (this.ethHelper) { |
||||
this.ethHelper = this.ethHelper.connect(providerOrSigner); |
||||
} |
||||
} |
||||
|
||||
static fromObject(data: any, signer?: ethers.Signer) { |
||||
if (!data.domain || !data.bridgeRouter) { |
||||
throw new Error('missing address'); |
||||
} |
||||
|
||||
const domain = data.domain; |
||||
const br = data.bridgeRouter.proxy ?? data.bridgeRouter; |
||||
const eh = data.ethHelper; |
||||
|
||||
return new BridgeContracts(domain, br, eh); |
||||
} |
||||
|
||||
static loadJson(filepath: string, signer?: ethers.Signer) { |
||||
return this.fromObject( |
||||
JSON.parse(fs.readFileSync(filepath, 'utf8')), |
||||
signer, |
||||
); |
||||
} |
||||
|
||||
toObject(): any { |
||||
const obj: any = { |
||||
bridgeRouter: this.bridgeRouter.address, |
||||
}; |
||||
if (this.ethHelper) { |
||||
obj.ethHelper = this.ethHelper.address; |
||||
} |
||||
return obj; |
||||
} |
||||
} |
@ -0,0 +1,81 @@ |
||||
import fs from 'fs'; |
||||
|
||||
import { ethers } from 'ethers'; |
||||
import { |
||||
Home, |
||||
Home__factory, |
||||
Replica, |
||||
Replica__factory, |
||||
} from '../../../../typechain/optics-core'; |
||||
import { Contracts } from '../../contracts'; |
||||
import { ReplicaInfo } from '../domains/domain'; |
||||
|
||||
type Address = string; |
||||
|
||||
type InternalReplica = { |
||||
domain: number; |
||||
contract: Replica; |
||||
}; |
||||
|
||||
export class CoreContracts extends Contracts { |
||||
readonly domain; |
||||
home: Home; |
||||
replicas: Map<number, InternalReplica>; |
||||
|
||||
constructor( |
||||
domain: number, |
||||
home: Address, |
||||
replicas: ReplicaInfo[], |
||||
signer?: ethers.Signer, |
||||
) { |
||||
super(domain, home, replicas, signer); |
||||
this.domain = domain; |
||||
this.home = new Home__factory(signer).attach(home); |
||||
|
||||
this.replicas = new Map(); |
||||
replicas.forEach((replica) => { |
||||
this.replicas.set(replica.domain, { |
||||
contract: new Replica__factory(signer).attach(replica.address), |
||||
domain: replica.domain, |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
connect(providerOrSigner: ethers.providers.Provider | ethers.Signer): void { |
||||
this.home = this.home.connect(providerOrSigner); |
||||
|
||||
Array.from(this.replicas.values()).forEach((replica: InternalReplica) => { |
||||
replica.contract = replica.contract.connect(providerOrSigner); |
||||
}); |
||||
} |
||||
|
||||
toObject(): any { |
||||
const replicas: ReplicaInfo[] = Array.from(this.replicas.values()).map( |
||||
(replica) => { |
||||
return { |
||||
domain: replica.domain, |
||||
address: replica.contract.address, |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
return { |
||||
home: this.home.address, |
||||
replicas: replicas, |
||||
}; |
||||
} |
||||
|
||||
static fromObject(data: any, signer?: ethers.Signer): CoreContracts { |
||||
if (!data.domain || !data.home || !data.replicas) { |
||||
throw new Error('Missing key'); |
||||
} |
||||
return new CoreContracts(data.domain, data.home, data.replicas, signer); |
||||
} |
||||
|
||||
static loadJson(filepath: string, signer?: ethers.Signer) { |
||||
return this.fromObject( |
||||
JSON.parse(fs.readFileSync(filepath, 'utf8')), |
||||
signer, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,47 @@ |
||||
import { OpticsDomain } from './domain'; |
||||
|
||||
export const alfajores: OpticsDomain = { |
||||
name: 'alfajores', |
||||
domain: 1000, |
||||
bridgeRouter: '0xdaa6e362f9BE0CDaCe107b298639034b8dEC617a', |
||||
home: '0x47AaF05B1C36015eC186892C43ba4BaF91246aaA', |
||||
replicas: [ |
||||
{ domain: 2000, address: '0x7804079cF55110dE7Db5aA67eB1Be00cBE9CA526' }, |
||||
{ |
||||
domain: 3000, |
||||
address: '0x6B8D6947B9b70f3ff1b547a15B969F625d28104a', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const kovan: OpticsDomain = { |
||||
name: 'kovan', |
||||
domain: 3000, |
||||
bridgeRouter: '0x383Eb849c707fE38f3DfBF45679C0c6f21Ba82fF', |
||||
ethHelper: '0x6D84B823D7FB68E4d6f7Cc334fDd393f6C3a6980', |
||||
home: '0x5B55C29A10aEe6D5750F128C6a8f490de763ccc7', |
||||
replicas: [ |
||||
{ domain: 2000, address: '0xC1AB4d72548Cc1C248EAdcD340035C3b213a47C3' }, |
||||
{ |
||||
domain: 1000, |
||||
address: '0xE63E73339501EE3A8d2928d6C88cf30aC8556Ee0', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const rinkeby: OpticsDomain = { |
||||
name: 'rinkeby', |
||||
domain: 2000, |
||||
bridgeRouter: '0xE9fB0b6351Dec7d346282b8274653D36b8199AAF', |
||||
ethHelper: '0x7a539d7B7f4Acab1d7ce8b3681c3e286511ee444', |
||||
home: '0x6E6010E6bd43a9d2F7AE3b7eA9f61760e58758f3', |
||||
replicas: [ |
||||
{ domain: 1000, address: '0x6A5F9531D1877ebE96Bc0631DbF64BBCf1f7421c' }, |
||||
{ |
||||
domain: 3000, |
||||
address: '0x6554bc7a5C35bA64Bf48FA8a9e662d8808aaa890', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const devDomains = [alfajores, kovan, rinkeby]; |
@ -0,0 +1,14 @@ |
||||
import { Domain } from '../../domains'; |
||||
import { Address } from '../../utils'; |
||||
|
||||
export interface OpticsDomain extends Domain { |
||||
bridgeRouter: Address; |
||||
ethHelper?: Address; |
||||
home: Address; |
||||
replicas: ReplicaInfo[]; |
||||
} |
||||
|
||||
export interface ReplicaInfo { |
||||
domain: number; |
||||
address: Address; |
||||
} |
@ -0,0 +1,4 @@ |
||||
export { OpticsDomain, ReplicaInfo } from './domain'; |
||||
export { mainnetDomains } from './mainnet'; |
||||
export { devDomains } from './dev'; |
||||
export { stagingDomains } from './staging'; |
@ -0,0 +1,50 @@ |
||||
import { OpticsDomain } from './domain'; |
||||
|
||||
export const ethereum: OpticsDomain = { |
||||
name: 'ethereum', |
||||
domain: 6648936, |
||||
bridgeRouter: '0x6a39909e805A3eaDd2b61fFf61147796ca6aBB47', |
||||
ethHelper: '0xf1c1413096ff2278C3Df198a28F8D54e0369cF3A', |
||||
home: '0xf25C5932bb6EFc7afA4895D9916F2abD7151BF97', |
||||
replicas: [ |
||||
{ |
||||
domain: 1667591279, |
||||
address: '0x07b5B57b08202294E657D51Eb453A189290f6385', |
||||
}, |
||||
{ |
||||
domain: 1886350457, |
||||
address: '0x7725EadaC5Ee986CAc8317a1d2fB16e59e079E8b', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const polygon: OpticsDomain = { |
||||
name: 'polygon', |
||||
domain: 1886350457, |
||||
bridgeRouter: '0xf244eA81F715F343040569398A4E7978De656bf6', |
||||
ethHelper: '0xc494bFEE14b5E1E118F93CfedF831f40dFA720fA', |
||||
home: '0x97bbda9A1D45D86631b243521380Bc070D6A4cBD', |
||||
replicas: [ |
||||
{ domain: 6648936, address: '0xf25C5932bb6EFc7afA4895D9916F2abD7151BF97' }, |
||||
{ |
||||
domain: 1667591279, |
||||
address: '0x681Edb6d52138cEa8210060C309230244BcEa61b', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const celo: OpticsDomain = { |
||||
name: 'celo', |
||||
domain: 1667591279, |
||||
bridgeRouter: '0xf244eA81F715F343040569398A4E7978De656bf6', |
||||
home: '0x97bbda9A1D45D86631b243521380Bc070D6A4cBD', |
||||
replicas: [ |
||||
{ domain: 6648936, address: '0xf25c5932bb6efc7afa4895d9916f2abd7151bf97' }, |
||||
{ |
||||
domain: 1886350457, |
||||
address: '0x681Edb6d52138cEa8210060C309230244BcEa61b', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const mainnetDomains = [ethereum, celo, polygon]; |
@ -0,0 +1,47 @@ |
||||
import { OpticsDomain } from './domain'; |
||||
|
||||
export const alfajores: OpticsDomain = { |
||||
name: 'alfajores', |
||||
domain: 1000, |
||||
bridgeRouter: '0xd6930Ee55C141E5Bb4079d5963cF64320956bb3E', |
||||
home: '0x47AaF05B1C36015eC186892C43ba4BaF91246aaA', |
||||
replicas: [ |
||||
{ domain: 2000, address: '0x7804079cF55110dE7Db5aA67eB1Be00cBE9CA526' }, |
||||
{ |
||||
domain: 3000, |
||||
address: '0x6B8D6947B9b70f3ff1b547a15B969F625d28104a', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const kovan: OpticsDomain = { |
||||
name: 'kovan', |
||||
domain: 3000, |
||||
bridgeRouter: '0x359089D34687bDbFD019fCC5093fFC21bE9905f5', |
||||
ethHelper: '0x411ABcFD947212a0D64b97C9882556367b61704a', |
||||
home: '0x5B55C29A10aEe6D5750F128C6a8f490de763ccc7', |
||||
replicas: [ |
||||
{ domain: 2000, address: '0xC1AB4d72548Cc1C248EAdcD340035C3b213a47C3' }, |
||||
{ |
||||
domain: 1000, |
||||
address: '0xE63E73339501EE3A8d2928d6C88cf30aC8556Ee0', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const rinkeby: OpticsDomain = { |
||||
name: 'rinkeby', |
||||
domain: 2000, |
||||
bridgeRouter: '0x8FbEA25D0bFDbff68F2B920df180e9498E9c856A', |
||||
ethHelper: '0x1BEBC8F1260d16EE5d1CFEE9366bB474bD13DC5f', |
||||
home: '0x6E6010E6bd43a9d2F7AE3b7eA9f61760e58758f3', |
||||
replicas: [ |
||||
{ domain: 1000, address: '0x6A5F9531D1877ebE96Bc0631DbF64BBCf1f7421c' }, |
||||
{ |
||||
domain: 3000, |
||||
address: '0x6554bc7a5C35bA64Bf48FA8a9e662d8808aaa890', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export const stagingDomains = [alfajores, kovan, rinkeby]; |
@ -0,0 +1,17 @@ |
||||
export { BridgeContracts } from './contracts/BridgeContracts'; |
||||
export { CoreContracts } from './contracts/CoreContracts'; |
||||
export { |
||||
TransferMessage, |
||||
DetailsMessage, |
||||
RequestDetailsMesasage, |
||||
} from './messages/BridgeMessage'; |
||||
export { OpticsMessage } from './messages/OpticsMessage'; |
||||
export { ResolvedTokenInfo, TokenIdentifier } from './tokens'; |
||||
|
||||
export { |
||||
OpticsDomain, |
||||
mainnetDomains, |
||||
devDomains, |
||||
stagingDomains, |
||||
} from './domains'; |
||||
export { OpticsContext, mainnet, dev, staging } from './OpticsContext'; |
@ -0,0 +1,166 @@ |
||||
import { BigNumber } from '@ethersproject/bignumber'; |
||||
import { arrayify, hexlify } from '@ethersproject/bytes'; |
||||
import { BridgeContracts, CoreContracts, OpticsContext } from '..'; |
||||
import { ResolvedTokenInfo, TokenIdentifier } from '../tokens'; |
||||
import { DispatchEvent, OpticsMessage, parseMessage } from './OpticsMessage'; |
||||
|
||||
const ACTION_LEN = { |
||||
identifier: 1, |
||||
tokenId: 36, |
||||
transfer: 65, |
||||
details: 66, |
||||
requestDetails: 1, |
||||
}; |
||||
|
||||
type Transfer = { |
||||
action: 'transfer'; |
||||
to: string; |
||||
amount: BigNumber; |
||||
}; |
||||
|
||||
export type Details = { |
||||
action: 'details'; |
||||
name: string; |
||||
symbol: string; |
||||
decimals: number; |
||||
}; |
||||
|
||||
export type RequestDetails = { action: 'requestDetails' }; |
||||
|
||||
export type Action = Transfer | Details | RequestDetails; |
||||
|
||||
export type ParsedBridgeMessage<T extends Action> = { |
||||
token: TokenIdentifier; |
||||
action: T; |
||||
}; |
||||
|
||||
export type ParsedTransferMessage = ParsedBridgeMessage<Transfer>; |
||||
export type ParsedDetailsMessage = ParsedBridgeMessage<Details>; |
||||
export type ParsedRequestDetailsMesasage = ParsedBridgeMessage<RequestDetails>; |
||||
|
||||
function parseAction(buf: Uint8Array): Action { |
||||
if (buf.length === ACTION_LEN.requestDetails) { |
||||
return { action: 'requestDetails' }; |
||||
} |
||||
|
||||
// Transfer
|
||||
if (buf.length === ACTION_LEN.transfer) { |
||||
// trim identifer
|
||||
buf = buf.slice(ACTION_LEN.identifier); |
||||
return { |
||||
action: 'transfer', |
||||
to: hexlify(buf.slice(0, 32)), |
||||
amount: BigNumber.from(hexlify(buf.slice(32))), |
||||
}; |
||||
} |
||||
|
||||
// Details
|
||||
if (buf.length === ACTION_LEN.details) { |
||||
// trim identifer
|
||||
buf = buf.slice(ACTION_LEN.identifier); |
||||
// TODO(james): improve this to show real strings
|
||||
return { |
||||
action: 'details', |
||||
|
||||
name: hexlify(buf.slice(0, 32)), |
||||
symbol: hexlify(buf.slice(32, 64)), |
||||
decimals: buf[64], |
||||
}; |
||||
} |
||||
|
||||
throw new Error('Bad action'); |
||||
} |
||||
|
||||
function parseBody( |
||||
messageBody: string, |
||||
): ParsedTransferMessage | ParsedDetailsMessage | ParsedRequestDetailsMesasage { |
||||
const buf = arrayify(messageBody); |
||||
|
||||
const tokenId = buf.slice(0, 36); |
||||
const token = { |
||||
domain: Buffer.from(tokenId).readUInt32BE(0), |
||||
id: hexlify(buf.slice(4)), |
||||
}; |
||||
|
||||
const action = parseAction(buf.slice(36)); |
||||
const parsedMessage = { |
||||
action, |
||||
token, |
||||
}; |
||||
|
||||
switch (action.action) { |
||||
case 'transfer': |
||||
return parsedMessage as ParsedTransferMessage; |
||||
case 'details': |
||||
return parsedMessage as ParsedDetailsMessage; |
||||
case 'requestDetails': |
||||
return parsedMessage as ParsedRequestDetailsMesasage; |
||||
} |
||||
} |
||||
|
||||
class BridgeMessage<T extends Action> extends OpticsMessage { |
||||
readonly token: TokenIdentifier; |
||||
readonly action: T; |
||||
|
||||
readonly fromBridge: BridgeContracts; |
||||
readonly toBridge: BridgeContracts; |
||||
|
||||
constructor( |
||||
event: DispatchEvent, |
||||
parsed: ParsedBridgeMessage<T>, |
||||
context: OpticsContext, |
||||
) { |
||||
super(event, context); |
||||
|
||||
const fromBridge = context.getBridge(this.message.from); |
||||
const toBridge = context.getBridge(this.message.destination); |
||||
|
||||
if (!fromBridge || !toBridge) { |
||||
throw new Error('missing bridge'); |
||||
} |
||||
|
||||
this.fromBridge = fromBridge; |
||||
this.toBridge = toBridge; |
||||
this.token = parsed.token; |
||||
|
||||
this.action = parsed.action; |
||||
} |
||||
|
||||
static fromEvent( |
||||
event: DispatchEvent, |
||||
context: OpticsContext, |
||||
): TransferMessage | DetailsMessage | RequestDetailsMesasage { |
||||
// kinda hate this but ok
|
||||
const parsedEvent = parseMessage(event.args.message); |
||||
const parsed = parseBody(parsedEvent.body); |
||||
|
||||
switch (parsed.action.action) { |
||||
case 'transfer': |
||||
return new BridgeMessage( |
||||
event, |
||||
parsed as ParsedTransferMessage, |
||||
context, |
||||
); |
||||
case 'details': |
||||
return new BridgeMessage( |
||||
event, |
||||
parsed as ParsedDetailsMessage, |
||||
context, |
||||
); |
||||
case 'requestDetails': |
||||
return new BridgeMessage( |
||||
event, |
||||
parsed as ParsedRequestDetailsMesasage, |
||||
context, |
||||
); |
||||
} |
||||
} |
||||
|
||||
async asset(): Promise<ResolvedTokenInfo> { |
||||
return await this.context.tokenRepresentations(this.token); |
||||
} |
||||
} |
||||
|
||||
export type TransferMessage = BridgeMessage<Transfer>; |
||||
export type DetailsMessage = BridgeMessage<Details>; |
||||
export type RequestDetailsMesasage = BridgeMessage<RequestDetails>; |
@ -0,0 +1,106 @@ |
||||
import { BigNumber, BigNumberish } from '@ethersproject/bignumber'; |
||||
import { TypedEvent } from '../../../../typechain/optics-core/commons'; |
||||
import { arrayify, hexlify } from '@ethersproject/bytes'; |
||||
import { OpticsContext } from '..'; |
||||
|
||||
// match the typescript declaration
|
||||
export type DispatchEvent = TypedEvent< |
||||
[string, BigNumber, BigNumber, string, string] |
||||
> & { |
||||
args: { |
||||
messageHash: string; |
||||
leafIndex: BigNumber; |
||||
destinationAndNonce: BigNumber; |
||||
committedRoot: string; |
||||
message: string; |
||||
}; |
||||
}; |
||||
|
||||
export type ParsedMessage = { |
||||
from: number; |
||||
sender: string; |
||||
nonce: number; |
||||
destination: number; |
||||
recipient: string; |
||||
body: string; |
||||
}; |
||||
|
||||
enum MessageStatus { |
||||
None = 0, |
||||
Proven, |
||||
Processed, |
||||
} |
||||
|
||||
export function parseMessage(message: string): ParsedMessage { |
||||
const buf = Buffer.from(arrayify(message)); |
||||
|
||||
const from = buf.readUInt32BE(0); |
||||
const sender = hexlify(buf.slice(4, 36)); |
||||
const nonce = buf.readUInt32BE(36); |
||||
const destination = buf.readUInt32BE(40); |
||||
const recipient = hexlify(buf.slice(44, 76)); |
||||
const body = hexlify(buf.slice(76)); |
||||
|
||||
return { from, sender, nonce, destination, recipient, body }; |
||||
} |
||||
|
||||
export class OpticsMessage { |
||||
readonly event: DispatchEvent; |
||||
readonly messageHash: string; |
||||
readonly leafIndex: BigNumber; |
||||
readonly destinationAndNonce: BigNumber; |
||||
readonly committedRoot: string; |
||||
readonly message: ParsedMessage; |
||||
|
||||
protected context: OpticsContext; |
||||
|
||||
constructor(event: DispatchEvent, context: OpticsContext) { |
||||
this.event = event; |
||||
this.messageHash = event.args.messageHash; |
||||
this.leafIndex = event.args.leafIndex; |
||||
this.destinationAndNonce = event.args.destinationAndNonce; |
||||
this.committedRoot = event.args.committedRoot; |
||||
this.message = parseMessage(event.args.message); |
||||
|
||||
this.context = context; |
||||
} |
||||
|
||||
async status(): Promise<MessageStatus> { |
||||
const replica = this.context.getReplicaFor(this.from, this.destination); |
||||
if (!replica) { |
||||
throw new Error( |
||||
`No replica on ${this.destination} for home ${this.from}`, |
||||
); |
||||
} |
||||
|
||||
return await replica.messages(this.messageHash); |
||||
} |
||||
|
||||
get from(): number { |
||||
return this.message.from; |
||||
} |
||||
|
||||
get origin(): number { |
||||
return this.from; |
||||
} |
||||
|
||||
get sender(): string { |
||||
return this.message.sender; |
||||
} |
||||
|
||||
get nonce(): number { |
||||
return this.message.nonce; |
||||
} |
||||
|
||||
get destination(): number { |
||||
return this.message.destination; |
||||
} |
||||
|
||||
get recipient(): string { |
||||
return this.message.recipient; |
||||
} |
||||
|
||||
get body(): string { |
||||
return this.message.body; |
||||
} |
||||
} |
@ -0,0 +1,34 @@ |
||||
import * as ethers from 'ethers'; |
||||
|
||||
import { mainnet } from '.'; |
||||
|
||||
const celoTokenAddr = '0x471EcE3750Da237f93B8E339c536989b8978a438'; |
||||
|
||||
const amount = ethers.constants.WeiPerEther.mul(100); |
||||
const privkey = process.env.PRIVKEY_LMAO; |
||||
if (!privkey) { |
||||
throw new Error('set PRIVKEY_LMAO'); |
||||
} |
||||
|
||||
const celoRpc = 'https://forno.celo.org'; |
||||
mainnet.registerRpcProvider('celo', celoRpc); |
||||
mainnet.registerWalletSigner('celo', privkey); |
||||
|
||||
async function doThing() { |
||||
const address = await mainnet.getAddress('celo'); |
||||
if (!address) { |
||||
throw new Error('no address'); |
||||
} |
||||
|
||||
const tx = await mainnet.send( |
||||
'celo', |
||||
'ethereum', |
||||
{ domain: 'celo', id: celoTokenAddr }, |
||||
amount, |
||||
address, |
||||
); |
||||
console.log(`sendTx is ${tx.hash}`); |
||||
await tx.wait(1); |
||||
} |
||||
|
||||
doThing(); |
@ -0,0 +1,13 @@ |
||||
import { BytesLike } from 'ethers'; |
||||
import { ERC20 } from '../../../typechain/optics-xapps'; |
||||
|
||||
export interface TokenIdentifier { |
||||
domain: string | number; |
||||
id: BytesLike; |
||||
} |
||||
|
||||
export type ResolvedTokenInfo = { |
||||
domain: number; |
||||
id: BytesLike; |
||||
tokens: Map<number, ERC20>; |
||||
}; |
@ -0,0 +1,110 @@ |
||||
import * as ethers from 'ethers'; |
||||
import { Domain } from './domains'; |
||||
import { mainnetDomains } from './optics/domains/mainnet'; |
||||
|
||||
type Provider = ethers.providers.Provider; |
||||
|
||||
export class MultiProvider { |
||||
private domains: Map<number, Domain>; |
||||
private providers: Map<number, Provider>; |
||||
private signers: Map<number, ethers.Signer>; |
||||
|
||||
constructor() { |
||||
this.domains = new Map(); |
||||
this.providers = new Map(); |
||||
this.signers = new Map(); |
||||
} |
||||
|
||||
registerDomain(domain: Domain) { |
||||
this.domains.set(domain.domain, domain); |
||||
} |
||||
|
||||
getDomain(domain: number): Domain | undefined { |
||||
return this.domains.get(domain); |
||||
} |
||||
|
||||
get domainNumbers(): number[] { |
||||
return Array.from(this.domains.keys()); |
||||
} |
||||
|
||||
resolveDomain(nameOrDomain: string | number): number { |
||||
if (typeof nameOrDomain === 'string') { |
||||
return Array.from(this.domains.values()).filter( |
||||
(domain) => domain.name === nameOrDomain, |
||||
)[0].domain; |
||||
} else { |
||||
return nameOrDomain; |
||||
} |
||||
} |
||||
|
||||
registerProvider(nameOrDomain: string | number, provider: Provider) { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
|
||||
if (!this.domains.get(domain)) { |
||||
throw new Error('Must have domain to register provider'); |
||||
} |
||||
|
||||
this.providers.set(domain, provider); |
||||
const signer = this.signers.get(domain); |
||||
if (signer) { |
||||
this.signers.set(domain, signer.connect(provider)); |
||||
} |
||||
} |
||||
|
||||
registerRpcProvider(nameOrDomain: string | number, rpc: string) { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
|
||||
const provider = new ethers.providers.JsonRpcProvider(rpc); |
||||
this.registerProvider(domain, provider); |
||||
} |
||||
|
||||
getProvider(nameOrDomain: string | number): Provider | undefined { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
|
||||
return this.providers.get(domain); |
||||
} |
||||
|
||||
registerSigner(nameOrDomain: string | number, signer: ethers.Signer) { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
|
||||
const provider = this.providers.get(domain); |
||||
if (!provider && !signer.provider) { |
||||
throw new Error('Must have a provider before registering signer'); |
||||
} |
||||
|
||||
if (provider) { |
||||
this.signers.set(domain, signer.connect(provider)); |
||||
} else { |
||||
this.registerProvider(domain, signer.provider!); |
||||
this.signers.set(domain, signer); |
||||
} |
||||
} |
||||
|
||||
unregisterSigner(nameOrDomain: string | number) { |
||||
this.signers.delete(this.resolveDomain(nameOrDomain)); |
||||
} |
||||
|
||||
registerWalletSigner(nameOrDomain: string | number, privkey: string) { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
|
||||
const wallet = new ethers.Wallet(privkey); |
||||
this.registerSigner(domain, wallet); |
||||
} |
||||
|
||||
getSigner(nameOrDomain: string | number): ethers.Signer | undefined { |
||||
const domain = this.resolveDomain(nameOrDomain); |
||||
return this.signers.get(domain); |
||||
} |
||||
|
||||
getConnection( |
||||
nameOrDomain: string | number, |
||||
): ethers.Signer | ethers.providers.Provider | undefined { |
||||
return this.getSigner(nameOrDomain) ?? this.getProvider(nameOrDomain); |
||||
} |
||||
|
||||
async getAddress(nameOrDomain: string | number): Promise<string | undefined> { |
||||
const signer = this.getSigner(nameOrDomain); |
||||
|
||||
return await signer?.getAddress(); |
||||
} |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { BytesLike } from '@ethersproject/bytes'; |
||||
import { ethers } from 'ethers'; |
||||
|
||||
export type Address = string; |
||||
|
||||
// ensure that a bytes-like is 32 long. left-pad with 0s if not
|
||||
export function canonizeId(data: BytesLike): Uint8Array { |
||||
const buf = ethers.utils.arrayify(data); |
||||
if (buf.length > 32) { |
||||
throw new Error('Too long'); |
||||
} |
||||
if (buf.length !== 20 && buf.length != 32) { |
||||
throw new Error('bad input, expect address or bytes32'); |
||||
} |
||||
return ethers.utils.zeroPad(buf, 32); |
||||
} |
@ -0,0 +1,72 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */ |
||||
|
||||
/* Basic Options */ |
||||
// "incremental": true, /* Enable incremental compilation */ |
||||
"target": "ES2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, |
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, |
||||
// "lib": [], /* Specify library files to be included in the compilation. */ |
||||
// "allowJs": true, /* Allow javascript files to be compiled. */ |
||||
// "checkJs": true, /* Report errors in .js files. */ |
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ |
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */ |
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ |
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */ |
||||
// "outFile": "./", /* Concatenate and emit output to single file. */ |
||||
// "outDir": "./", /* Redirect output structure to the directory. */ |
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ |
||||
// "composite": true, /* Enable project compilation */ |
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ |
||||
// "removeComments": true, /* Do not emit comments to output. */ |
||||
// "noEmit": true, /* Do not emit outputs. */ |
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */ |
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ |
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ |
||||
|
||||
/* Strict Type-Checking Options */ |
||||
"strict": true /* Enable all strict type-checking options. */, |
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ |
||||
// "strictNullChecks": true, /* Enable strict null checks. */ |
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ |
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ |
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ |
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ |
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ |
||||
|
||||
/* Additional Checks */ |
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */ |
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */ |
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ |
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ |
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ |
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ |
||||
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ |
||||
|
||||
/* Module Resolution Options */ |
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ |
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ |
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ |
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ |
||||
// "typeRoots": [], /* List of folders to include type definitions from. */ |
||||
// "types": [], /* Type declaration files to be included in compilation. */ |
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ |
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, |
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ |
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ |
||||
|
||||
/* Source Map Options */ |
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ |
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ |
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ |
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ |
||||
|
||||
/* Experimental Options */ |
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ |
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ |
||||
|
||||
/* Advanced Options */ |
||||
"skipLibCheck": true /* Skip type checking of declaration files. */, |
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ |
||||
} |
||||
} |
Loading…
Reference in new issue