Skip to content

Commit

Permalink
wallet-ext: Implement naive Coin selection algorithm
Browse files Browse the repository at this point in the history
  • Loading branch information
666lcz authored and pchrysochoidis committed Jun 2, 2022
1 parent b180b66 commit 276b0eb
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 38 deletions.
4 changes: 2 additions & 2 deletions sdk/typescript/src/types/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ export function getObjectFields(
}

export function getMoveObject(
resp: GetObjectDataResponse
data: GetObjectDataResponse | SuiObject
): SuiMoveObject | undefined {
const suiObject = getObjectExistsResponse(resp);
const suiObject = 'data' in data ? data : getObjectExistsResponse(data);
if (suiObject?.data.dataType !== 'moveObject') {
return undefined;
}
Expand Down
55 changes: 54 additions & 1 deletion sdk/typescript/src/types/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export type SplitCoinResponse = {
export type TransactionResponse =
| {
EffectResponse: TransactionEffectsResponse;
// TODO: Add Publish, MergeCoin Response
// TODO: Add Publish Response
}
| {
SplitCoinResponse: SplitCoinResponse;
Expand Down Expand Up @@ -256,3 +256,56 @@ export function getTotalGasUsed(data: TransactionEffectsResponse): number {
gasSummary.storageRebate
);
}

/* --------------------------- TransactionResponse -------------------------- */

export function getTransactionEffectsResponse(
data: TransactionResponse
): TransactionEffectsResponse | undefined {
return 'EffectResponse' in data ? data.EffectResponse : undefined;
}

export function getSplitCoinResponse(
data: TransactionResponse
): SplitCoinResponse | undefined {
return 'SplitCoinResponse' in data ? data.SplitCoinResponse : undefined;
}

export function getMergeCoinResponse(
data: TransactionResponse
): MergeCoinResponse | undefined {
return 'MergeCoinResponse' in data ? data.MergeCoinResponse : undefined;
}

/**
* Get the updated coin after a merge.
* @param data the response for executing a merge coin transaction
* @returns the updated state of the primary coin after the merge
*/
export function getCoinAfterMerge(
data: TransactionResponse
): SuiObject | undefined {
return getMergeCoinResponse(data)?.updatedCoin;
}

/**
* Get the updated coin after a split.
* @param data the response for executing a Split coin transaction
* @returns the updated state of the original coin object used for the split
*/
export function getCoinAfterSplit(
data: TransactionResponse
): SuiObject | undefined {
return getSplitCoinResponse(data)?.updatedCoin;
}

/**
* Get the newly created coin after a split.
* @param data the response for executing a Split coin transaction
* @returns the updated state of the original coin object used for the split
*/
export function getNewlyCreatedCoinsAfterSplit(
data: TransactionResponse
): SuiObject[] | undefined {
return getSplitCoinResponse(data)?.newCoins;
}
4 changes: 2 additions & 2 deletions wallet/src/ui/app/pages/home/tokens/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { useMemo } from 'react';
import CoinBalance from '_components/coin-balance';
import ObjectsLayout from '_components/objects-layout';
import { useAppSelector } from '_hooks';
import { accountBalancesSelector } from '_redux/slices/account';
import { accountAggregateBalancesSelector } from '_redux/slices/account';

function TokensPage() {
const balances = useAppSelector(accountBalancesSelector);
const balances = useAppSelector(accountAggregateBalancesSelector);
const coinTypes = useMemo(() => Object.keys(balances), [balances]);
return (
<ObjectsLayout
Expand Down
70 changes: 54 additions & 16 deletions wallet/src/ui/app/pages/transfer-coin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ import Alert from '_components/alert';
import Loading from '_components/loading';
import LoadingIndicator from '_components/loading/LoadingIndicator';
import { useAppSelector, useAppDispatch } from '_hooks';
import { accountBalancesSelector } from '_redux/slices/account';
import { Coin, GAS_SYMBOL, GAS_TYPE_ARG } from '_redux/slices/sui-objects/Coin';
import {
accountAggregateBalancesSelector,
accountItemizedBalancesSelector,
} from '_redux/slices/account';
import {
Coin,
DEFAULT_GAS_BUDGET_FOR_TRANSFER,
GAS_SYMBOL,
GAS_TYPE_ARG,
} from '_redux/slices/sui-objects/Coin';
import { sendTokens } from '_redux/slices/transactions';
import { balanceFormatOptions } from '_shared/formatting';

Expand All @@ -24,7 +32,6 @@ import type { ChangeEventHandler } from 'react';
import st from './TransferCoin.module.scss';

// TODO: calculate the transfer gas fee
const GAS_FEE = 100;
const addressValidation = Yup.string()
.ensure()
.trim()
Expand All @@ -47,15 +54,28 @@ const validationSchema = Yup.object({
.min(1)
.max(Yup.ref('balance'))
.test(
'available-gas-check',
'Insufficient funds for gas',
'gas-balance-check',
'Insufficient SUI balance to cover gas fee',
(amount, ctx) => {
const { type, gasBalance } = ctx.parent;
let availableGas = BigInt(gasBalance || 0);
const { type, gasAggregateBalance } = ctx.parent;
let availableGas = BigInt(gasAggregateBalance || 0);
if (type === GAS_TYPE_ARG) {
availableGas -= BigInt(amount || 0);
}
return availableGas >= GAS_FEE;
// TODO: implement more sophisticated validation by taking
// the splitting/merging fee into account
return availableGas >= DEFAULT_GAS_BUDGET_FOR_TRANSFER;
}
)
.test(
'num-gas-coins-check',
'Need at least 2 SUI coins to transfer a SUI coin',
(amount, ctx) => {
const { type, gasBalances } = ctx.parent;
return (
type !== GAS_TYPE_ARG ||
(gasBalances && gasBalances.length) >= 2
);
}
)
.label('Amount'),
Expand All @@ -74,15 +94,20 @@ type FormValues = typeof initialValues;
function TransferCoinPage() {
const [searchParams] = useSearchParams();
const coinType = useMemo(() => searchParams.get('type'), [searchParams]);
const balances = useAppSelector(accountBalancesSelector);
const balances = useAppSelector(accountItemizedBalancesSelector);
const aggregateBalances = useAppSelector(accountAggregateBalancesSelector);
const coinBalance = useMemo(
() => (coinType && balances[coinType]) || null,
[coinType, balances]
() => (coinType && aggregateBalances[coinType]) || null,
[coinType, aggregateBalances]
);
const gasBalance = useMemo(
const gasBalances = useMemo(
() => balances[GAS_TYPE_ARG] || null,
[balances]
);
const gasAggregateBalance = useMemo(
() => aggregateBalances[GAS_TYPE_ARG] || null,
[aggregateBalances]
);
const coinSymbol = useMemo(
() => coinType && Coin.getCoinSymbol(coinType),
[coinType]
Expand Down Expand Up @@ -134,8 +159,21 @@ function TransferCoinPage() {
useEffect(() => {
setFieldValue('balance', coinBalance?.toString() || '0');
setFieldValue('type', coinType);
setFieldValue('gasBalance', gasBalance?.toString() || '0');
}, [coinBalance, coinType, gasBalance, setFieldValue]);
setFieldValue(
'gasBalances',
gasBalances?.map((b: bigint) => b.toString()) || []
);
setFieldValue(
'gasAggregateBalance',
gasAggregateBalance?.toString() || '0'
);
}, [
coinBalance,
coinType,
gasAggregateBalance,
gasBalances,
setFieldValue,
]);
useEffect(() => {
setSendError(null);
}, [amount, recipientAddress]);
Expand Down Expand Up @@ -210,8 +248,8 @@ function TransferCoinPage() {
</span>
</div>
<div className={st.group}>
* Total transaction fee estimate (gas cost): {GAS_FEE}{' '}
{GAS_SYMBOL}
* Total transaction fee estimate (gas cost):{' '}
{DEFAULT_GAS_BUDGET_FOR_TRANSFER} {GAS_SYMBOL}
</div>
{sendError ? (
<div className={st.group}>
Expand Down
20 changes: 19 additions & 1 deletion wallet/src/ui/app/redux/slices/account/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ export const accountCoinsSelector = createSelector(
}
);

export const accountBalancesSelector = createSelector(
// return an aggregate balance for each coin type
export const accountAggregateBalancesSelector = createSelector(
accountCoinsSelector,
(coins) => {
return coins.reduce((acc, aCoin) => {
Expand All @@ -107,6 +108,23 @@ export const accountBalancesSelector = createSelector(
}
);

// return a list of balances for each coin object for each coin type
export const accountItemizedBalancesSelector = createSelector(
accountCoinsSelector,
(coins) => {
return coins.reduce((acc, aCoin) => {
const coinType = Coin.getCoinTypeArg(aCoin);
if (coinType) {
if (typeof acc[coinType] === 'undefined') {
acc[coinType] = [];
}
acc[coinType].push(Coin.getBalance(aCoin));
}
return acc;
}, {} as Record<string, bigint[]>);
}
);

export const accountNftsSelector = createSelector(
suiObjectsAdapterSelectors.selectAll,
(allSuiObjects) => {
Expand Down
76 changes: 61 additions & 15 deletions wallet/src/ui/app/redux/slices/sui-objects/Coin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { isSuiMoveObject } from '@mysten/sui.js';
import {
getCoinAfterMerge,
getMoveObject,
isSuiMoveObject,
} from '@mysten/sui.js';

import type {
ObjectId,
Expand All @@ -14,6 +18,9 @@ import type {

const COIN_TYPE = '0x2::Coin::Coin';
const COIN_TYPE_ARG_REGEX = /^0x2::Coin::Coin<(.+)>$/;
export const DEFAULT_GAS_BUDGET_FOR_SPLIT = 1000;
export const DEFAULT_GAS_BUDGET_FOR_MERGE = 500;
export const DEFAULT_GAS_BUDGET_FOR_TRANSFER = 100;
export const GAS_TYPE_ARG = '0x2::SUI::SUI';
export const GAS_SYMBOL = 'SUI';

Expand All @@ -28,11 +35,16 @@ export class Coin {
return res ? res[1] : null;
}

public static isSUI(obj: SuiMoveObject) {
const arg = Coin.getCoinTypeArg(obj);
return arg ? Coin.getCoinSymbol(arg) === 'SUI' : false;
}

public static getCoinSymbol(coinTypeArg: string) {
return coinTypeArg.substring(coinTypeArg.lastIndexOf(':') + 1);
}

public static getBalance(obj: SuiMoveObject) {
public static getBalance(obj: SuiMoveObject): bigint {
return BigInt(obj.fields.balance);
}

Expand All @@ -55,36 +67,50 @@ export class Coin {
public static async transferCoin(
signer: RawSigner,
coins: SuiMoveObject[],
amount: BigInt,
amount: bigint,
recipient: SuiAddress
): Promise<TransactionResponse> {
if (coins.length < 2) {
throw new Error(`Not enough coins to transfer`);
}
const coin = await Coin.selectCoin(coins, amount);
const coin = await Coin.selectCoin(signer, coins, amount);
return await signer.transferCoin({
objectId: coin,
gasBudget: 1000,
gasBudget: DEFAULT_GAS_BUDGET_FOR_TRANSFER,
recipient: recipient,
});
}

private static async selectCoin(
signer: RawSigner,
coins: SuiMoveObject[],
amount: BigInt
amount: bigint
): Promise<ObjectId> {
const coin = await Coin.selectCoinForSplit(coins, amount);
// TODO: Split coin not implemented yet
return Coin.getID(coin);
const coin = await Coin.selectCoinForSplit(signer, coins, amount);
const coinID = Coin.getID(coin);
const balance = Coin.getBalance(coin);
if (balance === amount) {
return coinID;
} else if (balance > amount) {
await signer.splitCoin({
coinObjectId: coinID,
gasBudget: DEFAULT_GAS_BUDGET_FOR_SPLIT,
splitAmounts: [Number(balance - amount)],
});
return coinID;
} else {
throw new Error(`Insufficient balance`);
}
}

private static async selectCoinForSplit(
signer: RawSigner,
coins: SuiMoveObject[],
amount: BigInt
amount: bigint
): Promise<SuiMoveObject> {
// Sort coins by balance in an ascending order
coins.sort();
coins.sort((a, b) =>
Coin.getBalance(a) - Coin.getBalance(b) > 0 ? 1 : -1
);

// return the coin with the smallest balance that is greater than or equal to the amount
const coinWithSufficientBalance = coins.find(
(c) => Coin.getBalance(c) >= amount
);
Expand All @@ -93,6 +119,26 @@ export class Coin {
}

// merge coins to have a coin with sufficient balance
throw new Error(`Merge coin Not implemented`);
// we will start from the coins with the largest balance
// and end with the coin with the second smallest balance(i.e., i > 0 instead of i >= 0)
// we cannot merge coins with the smallest balance because we
// need to have a separate coin to pay for the gas
// TODO: there's some edge cases here. e.g., the total balance is enough before spliting/merging
// but not enough if we consider the cost of splitting and merging.
let primaryCoin = coins[coins.length - 1];
for (let i = coins.length - 2; i > 0; i--) {
const mergeTxn = await signer.mergeCoin({
primaryCoin: Coin.getID(primaryCoin),
coinToMerge: Coin.getID(coins[i]),
gasBudget: DEFAULT_GAS_BUDGET_FOR_MERGE,
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
primaryCoin = getMoveObject(getCoinAfterMerge(mergeTxn)!)!;
if (Coin.getBalance(primaryCoin) >= amount) {
return primaryCoin;
}
}
// primary coin might have a balance smaller than the `amount`
return primaryCoin;
}
}
2 changes: 1 addition & 1 deletion wallet/src/ui/app/redux/slices/transactions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { AppThunkConfig } from '_store/thunk-extras';

type SendTokensTXArgs = {
tokenTypeArg: string;
amount: BigInt;
amount: bigint;
recipientAddress: SuiAddress;
};
type TransactionResult = { EffectResponse: TransactionEffectsResponse };
Expand Down

0 comments on commit 276b0eb

Please sign in to comment.