Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add utilities for formatting token amounts (formatTokenAmount and parseTokenAmount) #667

Merged
merged 14 commits into from
Sep 6, 2023
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { Claimant } from './claimant';
export { Networks } from './network';
export { StrKey } from './strkey';
export { SignerKey } from './signerkey';
export { Soroban } from './soroban';
export {
decodeAddressToMuxedAccount,
encodeMuxedAccountToAddress,
Expand Down
4 changes: 3 additions & 1 deletion src/operations/create_account.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export function createAccount(opts) {
throw new Error('destination is invalid');
}
if (!this.isValidAmount(opts.startingBalance, true)) {
throw new TypeError(this.constructAmountRequirementsError('startingBalance'))
throw new TypeError(
this.constructAmountRequirementsError('startingBalance')
);
}
const attributes = {};
attributes.destination = Keypair.fromPublicKey(
Expand Down
4 changes: 2 additions & 2 deletions src/operations/liquidity_pool_deposit.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ export function liquidityPoolDeposit(opts = {}) {
attributes.liquidityPoolId = xdr.PoolId.fromXDR(liquidityPoolId, 'hex');

if (!this.isValidAmount(maxAmountA, true)) {
throw new TypeError(this.constructAmountRequirementsError('maxAmountA'))
throw new TypeError(this.constructAmountRequirementsError('maxAmountA'));
}
attributes.maxAmountA = this._toXDRAmount(maxAmountA);

if (!this.isValidAmount(maxAmountB, true)) {
throw new TypeError(this.constructAmountRequirementsError('maxAmountB'))
throw new TypeError(this.constructAmountRequirementsError('maxAmountB'));
}
attributes.maxAmountB = this._toXDRAmount(maxAmountB);

Expand Down
6 changes: 3 additions & 3 deletions src/operations/liquidity_pool_withdraw.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ export function liquidityPoolWithdraw(opts = {}) {
attributes.liquidityPoolId = xdr.PoolId.fromXDR(opts.liquidityPoolId, 'hex');

if (!this.isValidAmount(opts.amount)) {
throw new TypeError(this.constructAmountRequirementsError('amount'))
throw new TypeError(this.constructAmountRequirementsError('amount'));
}
attributes.amount = this._toXDRAmount(opts.amount);

if (!this.isValidAmount(opts.minAmountA, true)) {
throw new TypeError(this.constructAmountRequirementsError('minAmountA'))
throw new TypeError(this.constructAmountRequirementsError('minAmountA'));
}
attributes.minAmountA = this._toXDRAmount(opts.minAmountA);

if (!this.isValidAmount(opts.minAmountB, true)) {
throw new TypeError(this.constructAmountRequirementsError('minAmountB'))
throw new TypeError(this.constructAmountRequirementsError('minAmountB'));
}
attributes.minAmountB = this._toXDRAmount(opts.minAmountB);

Expand Down
69 changes: 69 additions & 0 deletions src/soroban.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Soroban helper class
* formatting, parsing, and etc
* @class Soroban
*/
export class Soroban {
/**
* All arithmetic inside the contract is performed on integers to
* avoid potential precision and consistency issues of floating-point
*
* This function takes the smart contract value and its decimals (if the token has any) and returns a display value
* @param {string} amount - the token amount you want to display
* @param {number} decimals - specify how many decimal places a token has
* @returns {string} - display value
*/
static formatTokenAmount(amount, decimals) {
let formatted = amount;

if (amount.includes('.')) {
throw new Error('No decimal is allowed');
}

if (decimals > 0) {
if (decimals > formatted.length) {
formatted = [
'0',
formatted.toString().padStart(decimals, '0')
].join('.');
} else {
formatted = [
formatted.slice(0, -decimals),
formatted.slice(-decimals)
].join('.');
}
}

// remove trailing zero if any
return formatted.replace(/(\.\d*?)0+$/, '$1');
}

/**
* parse token amount to use it on smart contract
jeesunikim marked this conversation as resolved.
Show resolved Hide resolved
*
* This function takes the display value and its decimals (if the token has
* any) and returns a string that'll be used within the smart contract.
* @param {string} value - the token amount you want to use it on smart contract
* @param {number} decimals - specify how many decimal places a token has
* @returns {string} - smart contract value
*
*
* @example
* const displayValueAmount = "123.4560"
* const parsedAmountForSmartContract = parseTokenAmount("123.4560", 5);
* parsedAmountForSmartContract === "12345600"
*/
static parseTokenAmount(value, decimals) {
jeesunikim marked this conversation as resolved.
Show resolved Hide resolved
const [whole, fraction, ...rest] = value.split('.').slice();

if (rest.length) {
throw new Error(`Invalid decimal value: ${value}`);
}

const shifted = BigInt(
whole + (fraction?.padEnd(decimals, '0') ?? '0'.repeat(decimals))
);

return shifted.toString();
}
}
10 changes: 6 additions & 4 deletions test/unit/operation_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2478,8 +2478,9 @@ describe('Operation', function () {
);

opts.maxPrice = '0.55';
expect(() => StellarBase.Operation.liquidityPoolDeposit(opts)).to.not
.throw();
expect(() =>
StellarBase.Operation.liquidityPoolDeposit(opts)
).to.not.throw();
});

it('throws an error if prices are negative', function () {
Expand Down Expand Up @@ -2655,8 +2656,9 @@ describe('Operation', function () {
);

opts.minAmountB = '20000';
expect(() => StellarBase.Operation.liquidityPoolWithdraw(opts)).to.not
.throw();
expect(() =>
StellarBase.Operation.liquidityPoolWithdraw(opts)
).to.not.throw();
});

it('creates a liquidityPoolWithdraw', function () {
Expand Down
85 changes: 85 additions & 0 deletions test/unit/soroban_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
describe('Soroban', function () {
describe('formatTokenAmount', function () {
const SUCCESS_TEST_CASES = [
{ amount: '1000000001', decimals: 7, expected: '100.0000001' },
{ amount: '10000000010', decimals: 5, expected: '100000.0001' },
{ amount: '10000000010', decimals: 0, expected: '10000000010' },
{ amount: '10000', decimals: 10, expected: '0.000001' },
{ amount: '1567890', decimals: 10, expected: '0.000156789' },
{ amount: '1230', decimals: 0, expected: '1230' }
];

const FAILED_TEST_CASES = [
{
amount: '1000000001.1',
decimals: 7,
expected: /No decimal is allowed/
},
{
amount: '10000.00001.1',
decimals: 4,
expected: /No decimal is allowed/
}
];

SUCCESS_TEST_CASES.forEach((testCase) => {
it(`returns ${testCase.expected} for ${testCase.amount} with ${testCase.decimals} decimals`, function () {
expect(
StellarBase.Soroban.formatTokenAmount(
testCase.amount,
testCase.decimals
)
).to.equal(testCase.expected);
});
});

FAILED_TEST_CASES.forEach((testCase) => {
it(`fails on the input with decimals`, function () {
expect(() =>
StellarBase.Soroban.formatTokenAmount(
testCase.amount,
testCase.decimals
)
).to.throw(testCase.expected);
});
});
jeesunikim marked this conversation as resolved.
Show resolved Hide resolved
});

describe('parseTokenAmount', function () {
const SUCCESS_TEST_CASES = [
{ amount: '100', decimals: 2, expected: '10000' },
{ amount: '123.4560', decimals: 5, expected: '12345600' },
{ amount: '100', decimals: 5, expected: '10000000' }
];

const FAILED_TEST_CASES = [
{
amount: '1000000.001.1',
decimals: 7,
expected: /Invalid decimal value/i
}
];

SUCCESS_TEST_CASES.forEach((testCase) => {
it(`returns ${testCase.expected} for ${testCase.amount} of a token with ${testCase.decimals} decimals`, function () {
expect(
StellarBase.Soroban.parseTokenAmount(
testCase.amount,
testCase.decimals
)
).to.equal(testCase.expected);
});
});

FAILED_TEST_CASES.forEach((testCase) => {
it(`fails on the input with more than one decimals`, function () {
expect(() =>
StellarBase.Soroban.parseTokenAmount(
testCase.amount,
testCase.decimals
)
).to.throw(testCase.expected);
});
});
});
});