Skip to content

Commit

Permalink
Merge pull request #498 from onflow:transaction-reducer
Browse files Browse the repository at this point in the history
transaction reducer
  • Loading branch information
tombeckenham authored Feb 12, 2025
2 parents 37c5123 + 052f1a7 commit f44f547
Show file tree
Hide file tree
Showing 11 changed files with 649 additions and 260 deletions.
64 changes: 50 additions & 14 deletions src/shared/types/transaction-types.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,62 @@
import { type TokenInfo } from 'flow-native-token-registry';

import { type Contact } from './network-types';
import { type CoinItem, type WalletAddress } from './wallet-types';

// Define the base token types
export type TokenType = 'FT' | 'Flow';

// Define the network types
export type NetworkType = 'Evm' | 'Cadence' | 'Child';

// Define the transaction direction
export interface TransactionDirection {
from: NetworkType;
to: NetworkType;
}

export interface TransactionState {
type: TokenType;
direction: TransactionDirection;
status: 'pending' | 'success' | 'failed';
export type TransactionStateString = `${TokenType}from${NetworkType}to${NetworkType}`;

export type TransactionState = {
// A unique key for the transaction state
currentTxState: TransactionStateString | '';
// the root account that owns the account we're sending from
rootAddress: WalletAddress | '';

// the address of the account we're sending from
fromAddress: WalletAddress | '';
// the network type of the root address
fromNetwork: NetworkType;
// the contact of the from address (if it exists)
fromContact?: Contact;

// the address of the to address
toAddress: WalletAddress | '';
// the network type of the to address
toNetwork: NetworkType;
// the contact of the to address (if it exists)
toContact?: Contact;

// the token we've selected for the transaction
selectedToken: TokenInfo;

// the coin info of the selected token
coinInfo: CoinItem;

// the type of token we're sending
tokenType: TokenType;

// the amount of the transaction as a decimal string
amount: string;
fromAddress: string;
toAddress: string;
hash?: string;
}
// the fiat amount of the transaction as a decimal string
fiatAmount: string;
// the currency of the fiat amount (note we only support USD for now)
fiatCurrency: 'USD';
// what did the user enter the value in - fiat or coin
fiatOrCoin: 'fiat' | 'coin';
// whether the balance was exceeded
balanceExceeded: boolean;

export type TransactionStateString = `${TokenType}from${NetworkType}to${NetworkType}`;
// the status of the transaction
status?: 'pending' | 'success' | 'failed';
// The transaction if of the transaction
txId?: string;
};

// Type for the mapping
export type DecimalMapping = {
Expand Down
6 changes: 6 additions & 0 deletions src/shared/utils/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export const isValidFlowAddress = (address) => {
return regex.test(address);
};

export const isValidAddress = (address: unknown) => {
return (
typeof address === 'string' && (isValidEthereumAddress(address) || isValidFlowAddress(address))
);
};

export const ensureEvmAddressPrefix = (address) => {
const cleanAddress = address.startsWith('0x') ? address.slice(2) : address;

Expand Down
251 changes: 251 additions & 0 deletions src/ui/reducers/transaction-reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import BN from 'bignumber.js';
import type { TokenInfo } from 'flow-native-token-registry';

import { type Contact } from '@/shared/types/network-types';
import type {
NetworkType,
TokenType,
TransactionState,
TransactionStateString,
} from '@/shared/types/transaction-types';
import { type CoinItem, type WalletAddress } from '@/shared/types/wallet-types';
import { isValidEthereumAddress } from '@/shared/utils/address';

import { getMaxDecimals, stripEnteredAmount, stripFinalAmount } from '../utils/number';

export const INITIAL_TRANSACTION_STATE: TransactionState = {
currentTxState: '',
rootAddress: '',
fromAddress: '',
tokenType: 'FT',
fromNetwork: 'Evm',
toNetwork: 'Evm',
toAddress: '',
selectedToken: {
name: 'Flow',
address: '0x4445e7ad11568276',
contractName: 'FlowToken',
path: {
balance: '/public/flowTokenBalance',
receiver: '/public/flowTokenReceiver',
vault: '/storage/flowTokenVault',
},
logoURI:
'https://cdn.jsdelivr.net/gh/FlowFans/flow-token-list@main/token-registry/A.1654653399040a61.FlowToken/logo.svg',
decimals: 8,
symbol: 'flow',
},
coinInfo: {
coin: '',
unit: '',
balance: 0,
price: 0,
change24h: 0,
total: 0,
icon: '',
},
amount: '0.0',
fiatAmount: '0.0',
fiatCurrency: 'USD',
fiatOrCoin: 'coin',
balanceExceeded: false,
};

type TransactionAction =
| {
type: 'initTransactionState';
payload: {
rootAddress: WalletAddress;
fromAddress: WalletAddress;
fromContact?: Contact;
};
}
| {
type: 'setSelectedToken';
payload: {
tokenInfo: TokenInfo;
coinInfo: CoinItem;
};
}
| {
type: 'setTokenType';
payload: TokenType;
}
| {
type: 'setFromNetwork';
payload: NetworkType;
}
| {
type: 'setToNetwork';
payload: NetworkType;
}
| {
type: 'setToAddress';
payload: {
address: WalletAddress;
contact?: Contact;
};
}
| {
type: 'setAmount';
payload: string; // the amount of the transaction as a string
}
| {
type: 'setFiatOrCoin';
payload: 'fiat' | 'coin';
}
| {
type: 'switchFiatOrCoin';
}
| {
type: 'setAmountToMax';
};

export const getTransactionStateString = (state: TransactionState): TransactionStateString | '' => {
if (!state.tokenType || !state.fromNetwork || !state.toNetwork) return '';
return `${state.tokenType}from${state.fromNetwork}to${state.toNetwork}`;
};

const deepCopyTxState = (state: TransactionState): TransactionState => {
return {
...state,
selectedToken: { ...state.selectedToken },
coinInfo: { ...state.coinInfo },
};
};

export const transactionReducer = (
state: TransactionState,
action: TransactionAction
): TransactionState => {
switch (action.type) {
case 'initTransactionState': {
const { rootAddress, fromAddress, fromContact } = action.payload;
// Set from network based on the from address
const fromNetwork = isValidEthereumAddress(fromAddress)
? 'Evm'
: fromAddress === rootAddress
? 'Cadence'
: 'Child';
return { ...deepCopyTxState(state), rootAddress, fromAddress, fromNetwork, fromContact };
}
case 'setSelectedToken': {
// Set the token type based on the token symbol
const tokenType = action.payload.tokenInfo.symbol.toLowerCase() !== 'flow' ? 'FT' : 'Flow';
return {
...deepCopyTxState(state),
selectedToken: action.payload.tokenInfo,
tokenType,
coinInfo: action.payload.coinInfo,
};
}
case 'setToAddress': {
const { address, contact } = action.payload;
const toNetwork = isValidEthereumAddress(address)
? 'Evm'
: address === state.rootAddress
? 'Cadence'
: 'Child';
return { ...deepCopyTxState(state), toAddress: address, toNetwork, toContact: contact };
}
case 'setFiatOrCoin': {
return { ...deepCopyTxState(state), fiatOrCoin: action.payload };
}
case 'switchFiatOrCoin': {
return {
...deepCopyTxState(state),
fiatOrCoin: state.fiatOrCoin === 'fiat' ? 'coin' : 'fiat',
};
}
case 'setAmountToMax': {
// Check if entering in coin or fiat

if (state.fiatOrCoin === 'coin') {
return transactionReducer(state, {
type: 'setAmount',
payload: state.coinInfo.balance.toString(),
});
} else if (state.fiatOrCoin !== 'fiat') {
throw new Error('Not specified if entering in coin or fiat');
}
// This will calculate the max fiat amount that can be entered
const stateInCoinWithMaxAmount = transactionReducer(
{
...deepCopyTxState(state),
fiatOrCoin: 'coin',
},
{
type: 'setAmount',
payload: state.coinInfo.balance.toString(),
}
);
return { ...stateInCoinWithMaxAmount, fiatOrCoin: 'fiat' };
}
case 'setAmount': {
// Validate the amount
let amountInCoin = '0.0';
let amountInFiat = '0.0';
let balanceExceeded = false;
let remainingBalance = new BN(0);
const balance = new BN(state.coinInfo.balance || '0.0');
const price = new BN(state.coinInfo.price || '0.0');

if (state.fiatOrCoin === 'fiat') {
// Strip the amount entered to 3 decimal places
amountInFiat = stripEnteredAmount(action.payload, 3);
// Check if the balance is exceeded
const fiatAmountAsBN = new BN(stripFinalAmount(amountInFiat, 3));
const calculatedAmountInCoin = price.isZero() ? new BN(0) : fiatAmountAsBN.dividedBy(price);

// Figure out the amount in coin trimmed to the max decimals
if (calculatedAmountInCoin.isNaN()) {
amountInCoin = '0.0';
} else {
amountInCoin = calculatedAmountInCoin.toFixed(
getMaxDecimals(state.currentTxState!),
BN.ROUND_DOWN
);
}
// Calculate the remaining balance after the transaction
remainingBalance = balance.minus(new BN(amountInCoin));
} else if (state.fiatOrCoin === 'coin') {
// Check if the amount entered has too many decimal places
amountInCoin = stripEnteredAmount(action.payload, state.selectedToken.decimals);

// Check if the balance is exceeded
const amountBN = new BN(
stripFinalAmount(amountInCoin, state.selectedToken.decimals) || '0'
);
// Calculate the remaining balance after the transaction
remainingBalance = balance.minus(amountBN);
// Calculate fiat amount
const calculatedFiatAmount = amountBN.times(price);
amountInFiat = calculatedFiatAmount.toFixed(3, BN.ROUND_DOWN);
} else {
console.error('Not specified if entering in coin or fiat');
return state;
}
// Check the remaining balance to see if it's exceeded
if (remainingBalance.isLessThan(0)) {
balanceExceeded = true;
} else if (state.coinInfo.coin === 'flow' && remainingBalance.isLessThan(0.001)) {
// If we're less than the minimum allowed flow balance then that's also exceeding balance
balanceExceeded = true;
} else {
balanceExceeded = false;
}
if (amountInCoin === state.amount && amountInFiat === state.fiatAmount) {
// No changes to the state
return state;
}
// Return the new state with the amount (in coin), the fiat amount, and whether the balance was exceeded
return {
...deepCopyTxState(state),
amount: amountInCoin,
fiatAmount: amountInFiat,
balanceExceeded,
};
}
}
return state;
};
Loading

0 comments on commit f44f547

Please sign in to comment.