counterstake-sdk
Version:
Counterstake SDK for integrating cross-chain transactions in your dapps
330 lines (301 loc) • 13.4 kB
JavaScript
/*jslint node: true */
;
const EventEmitter = require('events');
const { ethers } = require("ethers");
const { isValidAddress } = require('obyte/lib/utils');
const { getSigner, NoMetamaskError } = require("./metamask.js");
const { getObyteClient, watchAA, resumeWatchingAAs } = require("./obyte-client.js");
const { findOswapPool, getOswapOutput } = require("./oswap.js");
const { getTokenInfo } = require("./tokens.js");
const { getBridges, getTransfer } = require("./cs-api");
const { BigNumber, utils: { parseUnits }, constants: { AddressZero } } = ethers;
const FORWARDER_AA = 'QRPI33656RFSEDEZHB5T2DNJ7R2WQQDS'; // double forwarder
const OSWAP_FORWARDER_AA = 'BFEMKOQR7G27YTAMNTIPDWHZNTBVMYFO'; // oswap forwarder that estimates final_price
const counterstakeAbi = [
"event NewClaim(uint indexed claim_num, address author_address, string sender_address, address recipient_address, string txid, uint32 txts, uint amount, int reward, uint stake, string data, uint32 expiry_ts)"
];
const exportAbi = [
`function transferToForeignChain(string memory foreign_address, string memory data, uint amount, int reward) payable external`
];
const importAbi = [
`function transferToHomeChain(string memory home_address, string memory data, uint amount, uint reward) external`
];
const erc20Abi = [
"function allowance(address owner, address spender) public view returns (uint256)",
"function approve(address spender, uint256 amount) public returns (bool)",
"function balanceOf(address account) public view returns (uint256)",
];
const csEvents = new EventEmitter();
const getAAPayload = (messages = []) => {
const dataMessage = messages.find(m => m.app === 'data');
return dataMessage ? dataMessage.payload : {};
};
function subscribeObyteClient(client) {
if (client.cs_subscribed)
return;
client.subscribe(async (err, result) => {
if (err) return null;
const { subject, body } = result[1];
// console.log('got', subject, body);
const { aa_address } = body;
if (subject === "light/aa_request") {
const { messages, unit, authors: [{ address }] } = body.unit;
const payload = getAAPayload(messages);
// new claim
if (payload.txid && payload.txts && payload.sender_address) {
csEvents.emit('NewClaim', { ...payload, claimant_address: address, network: 'Obyte', aa_address, claim_txid: unit, is_request: true });
}
else
console.log(`not a claim in ${unit}`);
}
else if (subject === "light/aa_response") {
const { response, bounced, trigger_unit, trigger_address } = body;
if (bounced) return null;
let { responseVars } = response;
if (!responseVars)
responseVars = {};
let { message } = responseVars;
if (!message)
message = '';
// new claim
if (responseVars.new_claim_num) {
const resp = await client.api.getJoint(trigger_unit);
if (!resp)
throw Error(`failed to get trigger ${trigger_unit}`);
const { unit: { messages } } = resp.joint;
const payload = getAAPayload(messages);
csEvents.emit('NewClaim', { ...payload, claimant_address: trigger_address, network: 'Obyte', aa_address, claim_txid: trigger_unit, claim_num: responseVars.new_claim_num });
}
else
console.log(`not a claim in AA response from ${trigger_unit}`);
}
});
client.onConnect(() => {
console.log(`connected`);
client.client.ws.addEventListener("close", () => {
console.log(`ws closed`);
});
resumeWatchingAAs(client);
});
client.cs_subscribed = true;
}
function toBN(amount, min_decimals, decimals) {
return parseUnits((+amount).toFixed(min_decimals), decimals);
}
/**
* Find a bridge that allows to transfer `src_asset` from `src_network` to `dst_network`
* @memberOf counterstake-sdk
* @param {string} src_network
* @param {string} dst_network
* @param {string} src_asset
* @param {boolean} testnet
* @return {Promise<Object>}
* @example
* const bridge = await findBridge(src_network, dst_network, src_asset, testnet);
*/
async function findBridge(src_network, dst_network, src_asset, testnet) {
const bridges = await getBridges(testnet, false); // use cache if available
for (let { export_aa, import_aa, home_network, foreign_network, home_asset, foreign_asset, home_symbol, foreign_symbol, home_asset_decimals, foreign_asset_decimals, min_expatriation_reward, min_repatriation_reward, max_expatriation_amount, max_repatriation_amount } of bridges) {
const min_decimals = Math.min(home_asset_decimals, foreign_asset_decimals);
if (src_network === home_network && dst_network === foreign_network && (src_asset === home_asset || src_asset === home_symbol))
return {
src_bridge_aa: export_aa,
dst_bridge_aa: import_aa,
type: 'expatriation',
src_asset: home_asset,
dst_asset: foreign_asset,
src_symbol: home_symbol,
dst_symbol: foreign_symbol,
src_decimals: home_asset_decimals,
dst_decimals: foreign_asset_decimals,
min_decimals,
min_reward: min_expatriation_reward,
max_amount: max_expatriation_amount,
};
if (src_network === foreign_network && dst_network === home_network && (src_asset === foreign_asset || src_asset === foreign_symbol))
return {
src_bridge_aa: import_aa,
dst_bridge_aa: export_aa,
type: 'repatriation',
src_asset: foreign_asset,
dst_asset: home_asset,
src_symbol: foreign_symbol,
dst_symbol: home_symbol,
src_decimals: foreign_asset_decimals,
dst_decimals: home_asset_decimals,
min_decimals,
min_reward: min_repatriation_reward,
max_amount: max_repatriation_amount,
};
}
return null;
}
async function approve(tokenAddress, spenderAddress, signer) {
if (typeof tokenAddress !== "string")
throw Error(`tokenAddress isn't valid`);
const sender_address = await signer.getAddress();
if (tokenAddress === AddressZero)
throw Error(`don't need to approve ETH`);
if (tokenAddress === spenderAddress)
return "spender is already the same as the token to be spent";
const token = new ethers.Contract(tokenAddress, erc20Abi, signer);
const allowance = await token.allowance(sender_address, spenderAddress);
if (allowance.gt(0)) {
console.log(`spender ${spenderAddress} already approved`);
return "already approved";
}
console.log(`will approve contract ${spenderAddress} to spend my token ${tokenAddress}`);
const res = await token.approve(spenderAddress, BigNumber.from(2).pow(256).sub(1));
console.log(`approval tx`, res);
await res.wait();
console.log(`approval mined`);
return res;
}
class NoBridgeError extends Error { }
class NoOswapPoolError extends Error { }
class AmountTooLargeError extends Error { }
class NotValidParamError extends Error { }
/**
* Send a cross-chain transfer from an EVM based chain to Obyte
* @memberOf counterstake-sdk
* @param {Object} transferInfo
* @return {Promise<string>}
* @example
* const txid = await transferEVM2Obyte({
amount: 100.0,
src_network: 'Ethereum',
src_asset: 'USDC',
dst_network: 'Obyte',
dst_asset: 'GBYTE',
recipient_address: 'EJC4A7WQGHEZEKW6RLO7F26SAR4LAQBU',
assistant_reward_percent: 1.0,
signer,
testnet: false,
obyteClient: client,
});
*/
async function transferEVM2Obyte({ amount, src_network, src_asset, dst_network, dst_asset, recipient_address, oswap_change_address, data, assistant_reward_percent, signer, testnet, obyteClient }) {
if (!signer) {
if (typeof window === 'undefined')
throw Error(`need a signer`);
// in browser, we can create a signer ourselves
signer = await getSigner(src_network, testnet);
}
if (data) {
if (typeof data !== 'object')
throw Error(`data must be an object`);
if (Array.isArray(data) && data.length === 0)
throw Error(`empty array`);
if (Object.keys(data).length === 0)
throw Error(`empty object`);
}
if (!recipient_address || !isValidAddress(recipient_address))
throw new NotValidParamError("recipient_address isn't valid");
const bridge = await findBridge(src_network, dst_network, src_asset, testnet);
if (!bridge)
throw new NoBridgeError(`no bridge from ${src_network} to ${dst_network} for ${src_asset}`);
const { src_bridge_aa, dst_bridge_aa, type, src_decimals, min_decimals, min_reward, max_amount, dst_asset: bridge_dst_asset, dst_symbol: bridge_dst_symbol } = bridge;
if (+amount > max_amount)
throw new AmountTooLargeError(`amount too large, assistants can help with only ${max_amount}`);
if (typeof assistant_reward_percent !== 'number')
throw new NotValidParamError("assistant_reward_percent isn't valid");
const reward = assistant_reward_percent/100 * amount + min_reward;
const bnAmount = toBN(amount, min_decimals, src_decimals);
const bnReward = toBN(reward, min_decimals, src_decimals);
const contract = new ethers.Contract(src_bridge_aa, type === 'expatriation' ? exportAbi : importAbi, signer);
let address;
let strData;
if (dst_asset === bridge_dst_asset || dst_asset === bridge_dst_symbol || !dst_asset) {
address = data ? FORWARDER_AA : recipient_address;
strData = data ? JSON.stringify({ address1: recipient_address, data1: data }) : '';
}
else { // transfer + swap
if (dst_network !== 'Obyte')
throw Error(`transfer+swap implemented for Obyte only`);
const dst_token = await getTokenInfo(dst_asset, testnet, obyteClient);
const oswap_aa = await findOswapPool(bridge_dst_asset, dst_token.asset, testnet, obyteClient);
if (!oswap_aa)
throw new NoOswapPoolError(`found no oswap pool that connects ${bridge_dst_asset} and ${dst_asset}`);
address = OSWAP_FORWARDER_AA;
const forwarder_data = { oswap_aa, address: recipient_address };
if (data)
forwarder_data.data = data;
if (oswap_change_address) // override if the recipient is an AA and you don't want it to receive the change
forwarder_data.change_address = oswap_change_address;
strData = JSON.stringify(forwarder_data);
}
if (dst_network === 'Obyte') {
const client = obyteClient || getObyteClient(testnet);
subscribeObyteClient(client);
watchAA(dst_bridge_aa, client);
}
else { // EVM
const csContract = new ethers.Contract(dst_bridge_aa, counterstakeAbi, signer);
csContract.on('NewClaim', (claim_num, author_address, sender_address, recipient_address, txid, txts, amount, reward, stake, data, expiry_ts, event) => {
const claim_txid = event.transactionHash;
csEvents.emit('NewClaim', { sender_address, address: recipient_address, txid, txts, amount, reward, data, claimant_address: author_address, network: dst_network, aa_address: dst_bridge_aa, claim_txid, claim_num, removed: event.removed });
});
}
let opts = {};
if (bridge.src_asset === AddressZero)
opts.value = bnAmount;
else
await approve(bridge.src_asset, src_bridge_aa, signer);
const f = type === 'expatriation' ? contract.transferToForeignChain : contract.transferToHomeChain;
const res = await f(address, strData, bnAmount, bnReward, opts);
console.log(res);
return res.hash;
}
/**
* Estimate the amount to be received from a cross-chain transfer
* @memberOf counterstake-sdk
* @param {Object} transferInfo
* @return {Promise<number>}
* @example
* const amountOut = await estimateOutput({
amount: 100.0,
src_network: 'Ethereum',
src_asset: 'USDC',
dst_network: 'Obyte',
dst_asset: 'GBYTE',
recipient_address: 'EJC4A7WQGHEZEKW6RLO7F26SAR4LAQBU',
assistant_reward_percent: 1.0,
testnet: false,
obyteClient: client,
});
*/
async function estimateOutput({ amount, src_network, src_asset, dst_network, dst_asset, assistant_reward_percent, testnet, obyteClient }) {
const bridge = await findBridge(src_network, dst_network, src_asset, testnet);
if (!bridge)
throw new NoBridgeError(`no bridge from ${src_network} to ${dst_network} for ${src_asset}`);
const { dst_decimals, min_reward, max_amount, dst_asset: bridge_dst_asset, dst_symbol: bridge_dst_symbol } = bridge;
if (+amount > max_amount)
throw new AmountTooLargeError(`amount too large, assistants can help with only ${max_amount}`);
if (typeof assistant_reward_percent !== 'number')
throw new NotValidParamError("assistant_reward_percent isn't valid")
const reward = assistant_reward_percent/100 * amount + min_reward;
const net_amount = +(amount - reward).toFixed(dst_decimals);
if (dst_asset === bridge_dst_asset || dst_asset === bridge_dst_symbol || !dst_asset)
return net_amount;
// else we need to swap after transferring
if (dst_network !== 'Obyte')
throw Error(`transfer+swap implemented for Obyte only`);
const dst_token = await getTokenInfo(dst_asset, testnet, obyteClient);
const oswap_aa = await findOswapPool(bridge_dst_asset, dst_token.asset, testnet, obyteClient);
if (!oswap_aa)
throw new NoOswapPoolError(`found no oswap pool that connects ${bridge_dst_symbol} and ${dst_asset}`);
const net_amount_in_pennies = Math.round(net_amount * 10 ** dst_decimals);
const out_amount_in_pennies = await getOswapOutput(oswap_aa, net_amount_in_pennies, bridge_dst_asset, testnet, obyteClient);
return +(out_amount_in_pennies / 10 ** dst_token.decimals).toFixed(dst_token.decimals);
}
exports.getBridges = getBridges;
exports.getTransfer = getTransfer;
exports.getObyteClient = getObyteClient;
exports.findOswapPool = findOswapPool;
exports.getOswapOutput = getOswapOutput;
exports.getTokenInfo = getTokenInfo;
exports.findBridge = findBridge;
exports.transferEVM2Obyte = transferEVM2Obyte;
exports.estimateOutput = estimateOutput;
exports.csEvents = csEvents;
exports.errors = { NoMetamaskError, NoBridgeError, NoOswapPoolError, AmountTooLargeError };