@shapeshiftoss/utils 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,8 @@ export * from './getChainShortName.js';
15
15
  export * from './getNativeFeeAssetReference.js';
16
16
  export * from './historyTimeframe.js';
17
17
  export * from './makeAsset/makeAsset.js';
18
+ export * from './paymentUri/constants.js';
19
+ export * from './paymentUri/paymentUri.js';
18
20
  export * from './promises.js';
19
21
  export * from './sha256.js';
20
22
  export * from './timeout.js';
package/dist/esm/index.js CHANGED
@@ -15,6 +15,8 @@ export * from './getChainShortName.js';
15
15
  export * from './getNativeFeeAssetReference.js';
16
16
  export * from './historyTimeframe.js';
17
17
  export * from './makeAsset/makeAsset.js';
18
+ export * from './paymentUri/constants.js';
19
+ export * from './paymentUri/paymentUri.js';
18
20
  export * from './promises.js';
19
21
  export * from './sha256.js';
20
22
  export * from './timeout.js';
@@ -0,0 +1,3 @@
1
+ import type { ChainId } from '@shapeshiftoss/caip';
2
+ export declare const CHAIN_ID_TO_URN_SCHEME: Partial<Record<ChainId, string>>;
3
+ export declare const URN_SCHEME_TO_CHAIN_ID: Partial<Record<string, ChainId>>;
@@ -0,0 +1,26 @@
1
+ import { arbitrumChainId, avalancheChainId, baseChainId, bchChainId, bscChainId, btcChainId, cosmosChainId, dogeChainId, ethChainId, gnosisChainId, ltcChainId, mayachainChainId, optimismChainId, polygonChainId, solanaChainId, thorchainChainId, tronChainId, zecChainId, } from '@shapeshiftoss/caip';
2
+ export const CHAIN_ID_TO_URN_SCHEME = {
3
+ [ethChainId]: 'ethereum',
4
+ [arbitrumChainId]: 'arbitrum',
5
+ [optimismChainId]: 'optimism',
6
+ [polygonChainId]: 'polygon',
7
+ [bscChainId]: 'smartchain',
8
+ [avalancheChainId]: 'avalanchec',
9
+ [baseChainId]: 'base',
10
+ [gnosisChainId]: 'xdai',
11
+ [btcChainId]: 'bitcoin',
12
+ [bchChainId]: 'bitcoincash',
13
+ [dogeChainId]: 'dogecoin',
14
+ [ltcChainId]: 'litecoin',
15
+ [zecChainId]: 'zcash',
16
+ [thorchainChainId]: 'thorchain',
17
+ [cosmosChainId]: 'cosmos',
18
+ [mayachainChainId]: 'mayachain',
19
+ [solanaChainId]: 'solana',
20
+ [tronChainId]: 'tron',
21
+ };
22
+ export const URN_SCHEME_TO_CHAIN_ID = {
23
+ ...Object.fromEntries(Object.entries(CHAIN_ID_TO_URN_SCHEME).map(([chainId, scheme]) => [scheme, chainId])),
24
+ // The app emitted doge: QR codes for a year before adopting Dogecoin Core's scheme
25
+ doge: dogeChainId,
26
+ };
@@ -0,0 +1,7 @@
1
+ import type { Asset } from '@shapeshiftoss/types';
2
+ export type BuildPaymentUriArgs = {
3
+ address: string;
4
+ asset: Pick<Asset, 'assetId' | 'chainId' | 'precision'>;
5
+ amountCryptoPrecision?: string;
6
+ };
7
+ export declare const buildPaymentUri: (args: BuildPaymentUriArgs) => string;
@@ -0,0 +1,92 @@
1
+ import { ASSET_NAMESPACE, CHAIN_NAMESPACE, fromAssetId, fromChainId } from '@shapeshiftoss/caip';
2
+ import { BigAmount } from '../bigAmount/bigAmount.js';
3
+ import { bn } from '../bignumber/bignumber.js';
4
+ import { CHAIN_ID_TO_URN_SCHEME } from './constants.js';
5
+ const toBaseUnit = (asset, amountCryptoPrecision) => BigAmount.fromPrecision({ value: amountCryptoPrecision, precision: asset.precision }).toBaseUnit();
6
+ // EIP-681 encourages scientific notation, and writes it without the exponent's plus sign
7
+ const toEip681Amount = (amountCryptoBaseUnit) => bn(amountCryptoBaseUnit).toExponential().replace('+', '').replace('e0', '');
8
+ const buildEvmUri = ({ address, asset, amountCryptoPrecision }) => {
9
+ const target = `@${Number(fromChainId(asset.chainId).chainReference)}`;
10
+ const { assetNamespace, assetReference } = fromAssetId(asset.assetId);
11
+ // transfer(address,uint256) is erc20's alone, so erc721 and erc1155 get no amount
12
+ const takesAmount = assetNamespace === ASSET_NAMESPACE.slip44 || assetNamespace === ASSET_NAMESPACE.erc20;
13
+ if (!amountCryptoPrecision || !takesAmount)
14
+ return `ethereum:${address}${target}`;
15
+ const amount = toEip681Amount(toBaseUnit(asset, amountCryptoPrecision));
16
+ return assetNamespace === ASSET_NAMESPACE.erc20
17
+ ? `ethereum:${assetReference}${target}/transfer?address=${address}&uint256=${amount}`
18
+ : `ethereum:${address}${target}?value=${amount}`;
19
+ };
20
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
21
+ // The same check as web3.js's PublicKey: base58 that decodes to exactly 32 bytes
22
+ const isSolanaPublicKey = (address) => {
23
+ let value = 0n;
24
+ for (const char of address) {
25
+ const digit = BASE58_ALPHABET.indexOf(char);
26
+ if (digit === -1)
27
+ return false;
28
+ value = value * 58n + BigInt(digit);
29
+ }
30
+ const leadingZeroBytes = address.length - address.replace(/^1+/, '').length;
31
+ const significantBytes = value === 0n ? 0 : Math.ceil(value.toString(16).length / 2);
32
+ return leadingZeroBytes + significantBytes === 32;
33
+ };
34
+ const buildSolanaUri = ({ address, asset, amountCryptoPrecision }) => {
35
+ if (!amountCryptoPrecision)
36
+ return address;
37
+ if (!isSolanaPublicKey(address))
38
+ throw new Error(`Invalid Solana address: ${address}`);
39
+ // Solana Pay normalises the amount rather than passing it through verbatim
40
+ const amount = bn(amountCryptoPrecision).toFixed();
41
+ const { assetNamespace, assetReference } = fromAssetId(asset.assetId);
42
+ if (assetNamespace === ASSET_NAMESPACE.splToken) {
43
+ // Solana Pay takes decimal ui units, and the recipient is the native account, not its ATA
44
+ return `solana:${address}?amount=${amount}&spl-token=${assetReference}`;
45
+ }
46
+ return `solana:${address}?amount=${amount}`;
47
+ };
48
+ const buildTonUri = ({ address, asset, amountCryptoPrecision }) => {
49
+ if (!amountCryptoPrecision)
50
+ return address;
51
+ const { assetNamespace, assetReference } = fromAssetId(asset.assetId);
52
+ const amount = toBaseUnit(asset, amountCryptoPrecision);
53
+ // A jetton transfer names its master contract, and its amount is in the jetton's own units
54
+ if (assetNamespace === ASSET_NAMESPACE.jetton) {
55
+ return `ton://transfer/${address}?jetton=${assetReference}&amount=${amount}`;
56
+ }
57
+ return `ton://transfer/${address}?amount=${amount}`;
58
+ };
59
+ // BIP-21 takes decimal coin units
60
+ const buildBip21Uri = ({ address, asset, amountCryptoPrecision }) => {
61
+ const scheme = CHAIN_ID_TO_URN_SCHEME[asset.chainId];
62
+ if (!amountCryptoPrecision || !scheme)
63
+ return address;
64
+ // CashAddr already carries its scheme
65
+ const target = address.startsWith(`${scheme}:`) ? address.slice(scheme.length + 1) : address;
66
+ return `${scheme}:${target}?amount=${bn(amountCryptoPrecision).toFixed()}`;
67
+ };
68
+ export const buildPaymentUri = (args) => {
69
+ const { asset, amountCryptoPrecision } = args;
70
+ if (amountCryptoPrecision) {
71
+ const amount = bn(amountCryptoPrecision);
72
+ if (!amount.isFinite() || amount.isNegative()) {
73
+ throw new Error(`Invalid payment amount: ${amountCryptoPrecision}`);
74
+ }
75
+ if ((amount.decimalPlaces() ?? 0) > asset.precision) {
76
+ throw new Error(`Payment amount ${amountCryptoPrecision} exceeds ${asset.precision} decimals`);
77
+ }
78
+ }
79
+ switch (fromChainId(args.asset.chainId).chainNamespace) {
80
+ case CHAIN_NAMESPACE.Utxo:
81
+ case CHAIN_NAMESPACE.CosmosSdk:
82
+ return buildBip21Uri(args);
83
+ case CHAIN_NAMESPACE.Evm:
84
+ return buildEvmUri(args);
85
+ case CHAIN_NAMESPACE.Solana:
86
+ return buildSolanaUri(args);
87
+ case CHAIN_NAMESPACE.Ton:
88
+ return buildTonUri(args);
89
+ default:
90
+ return args.address;
91
+ }
92
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildPaymentUri } from './paymentUri.js';
3
+ const asset = (assetId, chainId, precision) => ({
4
+ assetId,
5
+ chainId,
6
+ precision,
7
+ });
8
+ const BTC = asset('bip122:000000000019d6689c085ae165831e93/slip44:0', 'bip122:000000000019d6689c085ae165831e93', 8);
9
+ const BCH = asset('bip122:000000000000000000651ef99cb9fcbe/slip44:145', 'bip122:000000000000000000651ef99cb9fcbe', 8);
10
+ const ETH = asset('eip155:1/slip44:60', 'eip155:1', 18);
11
+ const BASE_ETH = asset('eip155:8453/slip44:60', 'eip155:8453', 18);
12
+ const USDC = asset('eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 'eip155:1', 6);
13
+ const FOX = asset('eip155:1/erc20:0xc770eefad204b5180df6a14ee197d99d808ee52d', 'eip155:1', 18);
14
+ const SOL = asset('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 9);
15
+ const WIF = asset('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 6);
16
+ const TON = asset('ton:mainnet/slip44:607', 'ton:mainnet', 9);
17
+ const TON_USDT = asset('ton:mainnet/jetton:EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', 'ton:mainnet', 6);
18
+ const ATOM = asset('cosmos:cosmoshub-4/slip44:118', 'cosmos:cosmoshub-4', 6);
19
+ const BTC_ADDRESS = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzz';
20
+ const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001';
21
+ const SOL_ADDRESS = 'GsbwXfJraMomNxBcjYLcG3mxkBUiyWXAB32fGbSMQRdW';
22
+ const TON_ADDRESS = 'UQBFtnj3-yYK4p7XqXsyC5LNxfL2Rm5m0m3F0pvfDy7YQ9tk';
23
+ describe('buildPaymentUri', () => {
24
+ it('builds a BIP-21 uri with a decimal coin amount', () => {
25
+ expect(buildPaymentUri({ address: BTC_ADDRESS, asset: BTC, amountCryptoPrecision: '0.05' })).toBe(`bitcoin:${BTC_ADDRESS}?amount=0.05`);
26
+ });
27
+ it('does not double-prefix a cashaddr that already carries its scheme', () => {
28
+ const address = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y';
29
+ expect(buildPaymentUri({ address, asset: BCH, amountCryptoPrecision: '1' })).toBe('bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y?amount=1');
30
+ });
31
+ it('carries the chain id so an l2 deposit cannot be paid on mainnet', () => {
32
+ expect(buildPaymentUri({ address: EVM_ADDRESS, asset: BASE_ETH, amountCryptoPrecision: '2' })).toBe(`ethereum:${EVM_ADDRESS}@8453?value=2e18`);
33
+ });
34
+ it('targets the contract and moves the destination into the call for an erc20', () => {
35
+ expect(buildPaymentUri({ address: EVM_ADDRESS, asset: USDC, amountCryptoPrecision: '1.5' })).toBe(`ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48@1/transfer?address=${EVM_ADDRESS}&uint256=1.5e6`);
36
+ });
37
+ it('keeps every digit of a full-precision erc20 amount', () => {
38
+ expect(buildPaymentUri({
39
+ address: EVM_ADDRESS,
40
+ asset: FOX,
41
+ amountCryptoPrecision: '123.123456789012345678',
42
+ })).toBe(`ethereum:0xc770eefad204b5180df6a14ee197d99d808ee52d@1/transfer?address=${EVM_ADDRESS}&uint256=1.23123456789012345678e20`);
43
+ expect(buildPaymentUri({ address: EVM_ADDRESS, asset: USDC, amountCryptoPrecision: '0.000001' })).toBe(`ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48@1/transfer?address=${EVM_ADDRESS}&uint256=1`);
44
+ });
45
+ it('builds a Solana Pay uri with ui units', () => {
46
+ expect(buildPaymentUri({ address: SOL_ADDRESS, asset: SOL, amountCryptoPrecision: '1.5' })).toBe(`solana:${SOL_ADDRESS}?amount=1.5`);
47
+ });
48
+ it('names the mint and keeps the native account as recipient for an spl token', () => {
49
+ expect(buildPaymentUri({ address: SOL_ADDRESS, asset: WIF, amountCryptoPrecision: '0.1' })).toBe(`solana:${SOL_ADDRESS}?amount=0.1&spl-token=EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm`);
50
+ });
51
+ it('builds a ton transfer link in nanotons', () => {
52
+ expect(buildPaymentUri({ address: TON_ADDRESS, asset: TON, amountCryptoPrecision: '1.5' })).toBe(`ton://transfer/${TON_ADDRESS}?amount=1500000000`);
53
+ });
54
+ it('names the jetton master and uses the jetton units for a jetton', () => {
55
+ expect(buildPaymentUri({ address: TON_ADDRESS, asset: TON_USDT, amountCryptoPrecision: '5' })).toBe(`ton://transfer/${TON_ADDRESS}?jetton=EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs&amount=5000000`);
56
+ });
57
+ it('uses the cosmos sdk scheme', () => {
58
+ const address = 'cosmos1zqf0dq3nl3fhr8xk3pd2yq2sxwv0h2gd8qzptc';
59
+ expect(buildPaymentUri({ address, asset: ATOM, amountCryptoPrecision: '1' })).toBe(`cosmos:${address}?amount=1`);
60
+ });
61
+ it('falls back to the bare address on a chain with no scheme', () => {
62
+ const address = '0x04a1b2c3';
63
+ const starknet = asset('starknet:SN_MAIN/slip44:9004', 'starknet:SN_MAIN', 18);
64
+ expect(buildPaymentUri({ address, asset: starknet, amountCryptoPrecision: '1' })).toBe(address);
65
+ });
66
+ it('omits the amount from schemes that need one, but keeps the evm chain id', () => {
67
+ expect(buildPaymentUri({ address: BTC_ADDRESS, asset: BTC })).toBe(BTC_ADDRESS);
68
+ expect(buildPaymentUri({ address: SOL_ADDRESS, asset: SOL })).toBe(SOL_ADDRESS);
69
+ expect(buildPaymentUri({ address: TON_ADDRESS, asset: TON_USDT })).toBe(TON_ADDRESS);
70
+ expect(buildPaymentUri({ address: EVM_ADDRESS, asset: BASE_ETH })).toBe(`ethereum:${EVM_ADDRESS}@8453`);
71
+ });
72
+ it('passes a zero amount through', () => {
73
+ expect(buildPaymentUri({ address: BTC_ADDRESS, asset: BTC, amountCryptoPrecision: '0' })).toBe(`bitcoin:${BTC_ADDRESS}?amount=0`);
74
+ expect(buildPaymentUri({ address: EVM_ADDRESS, asset: ETH, amountCryptoPrecision: '0' })).toBe(`ethereum:${EVM_ADDRESS}@1?value=0`);
75
+ });
76
+ it('throws on a Solana address that is not a public key, as Solana Pay did', () => {
77
+ expect(buildPaymentUri({ address: 'not-a-pubkey', asset: SOL })).toBe('not-a-pubkey');
78
+ expect(() => buildPaymentUri({ address: 'not-a-pubkey', asset: SOL, amountCryptoPrecision: '1' })).toThrow('Invalid Solana address');
79
+ expect(() => buildPaymentUri({ address: `${SOL_ADDRESS}1`, asset: SOL, amountCryptoPrecision: '1' })).toThrow('Invalid Solana address');
80
+ });
81
+ it('writes a scientific-notation amount as a plain decimal', () => {
82
+ expect(buildPaymentUri({ address: BTC_ADDRESS, asset: BTC, amountCryptoPrecision: '1e-8' })).toBe(`bitcoin:${BTC_ADDRESS}?amount=0.00000001`);
83
+ expect(buildPaymentUri({ address: SOL_ADDRESS, asset: SOL, amountCryptoPrecision: '1e-9' })).toBe(`solana:${SOL_ADDRESS}?amount=0.000000001`);
84
+ });
85
+ it('throws on an amount finer than the asset', () => {
86
+ expect(() => buildPaymentUri({ address: BTC_ADDRESS, asset: BTC, amountCryptoPrecision: '0.000000001' })).toThrow('exceeds 8 decimals');
87
+ expect(() => buildPaymentUri({ address: EVM_ADDRESS, asset: USDC, amountCryptoPrecision: '0.0000001' })).toThrow('exceeds 6 decimals');
88
+ });
89
+ it('throws on an amount no wallet could pay', () => {
90
+ expect(() => buildPaymentUri({ address: BTC_ADDRESS, asset: BTC, amountCryptoPrecision: '-1' })).toThrow('Invalid payment amount');
91
+ expect(() => buildPaymentUri({ address: EVM_ADDRESS, asset: ETH, amountCryptoPrecision: 'abc' })).toThrow('Invalid payment amount');
92
+ });
93
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshiftoss/utils",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "repository": "https://github.com/shapeshift/web",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,8 +25,8 @@
25
25
  "bignumber.js": "^9.3.1",
26
26
  "dayjs": "^1.11.3",
27
27
  "lodash-es": "^4.17.23",
28
- "@shapeshiftoss/caip": "^8.16.10",
29
- "@shapeshiftoss/types": "^9.0.1"
28
+ "@shapeshiftoss/types": "^9.0.1",
29
+ "@shapeshiftoss/caip": "^8.17.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/lodash-es": "^4.17.12"