Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 190 additions & 173 deletions README.md

Large diffs are not rendered by default.

1,425 changes: 713 additions & 712 deletions docs/API.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@amadeus-protocol/sdk",
"version": "1.2.0",
"version": "1.3.1",
"description": "Official TypeScript/JavaScript SDK for Amadeus Protocol - Core utilities for serialization, cryptography, transaction building, and API client",
"repository": {
"type": "git",
Expand Down
126 changes: 125 additions & 1 deletion src/__tests__/conversion.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { toAtomicAma, fromAtomicAma } from '../conversion'
import { toAtomicAma, toAtomicAmaString, fromAtomicAma, fromAtomicAmaString } from '../conversion'
import { AMA_TOKEN_DECIMALS_MULTIPLIER } from '../constants'

describe('Conversion Utilities', () => {
Expand Down Expand Up @@ -44,6 +44,76 @@ describe('Conversion Utilities', () => {
expect(toAtomicAma('0.000000001')).toBe(1)
expect(toAtomicAma('100')).toBe(100_000_000_000)
})

it('rejects strings that are not a plain decimal amount', () => {
expect(() => toAtomicAma('abc')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma(' ')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('1,5')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('1.5abc')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('1e3')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('0x10')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('1.2.3')).toThrow('Invalid AMA amount')
})

it('rejects negative amounts', () => {
expect(() => toAtomicAma(-1.5)).toThrow('Invalid AMA amount')
expect(() => toAtomicAma('-5')).toThrow('Invalid AMA amount')
expect(() => toAtomicAma(-0.000000001)).toThrow('Invalid AMA amount')
})

it('rejects amounts that are not finite numbers', () => {
expect(() => toAtomicAma(NaN)).toThrow('Invalid AMA amount')
expect(() => toAtomicAma(Infinity)).toThrow('Invalid AMA amount')
expect(() => toAtomicAma(-Infinity)).toThrow('Invalid AMA amount')
expect(() => toAtomicAma(null as unknown as number)).toThrow('Invalid AMA amount')
expect(() => toAtomicAma(undefined as unknown as number)).toThrow('Invalid AMA amount')
})

it('truncates string precision beyond 9 decimals instead of rounding up', () => {
expect(toAtomicAma('1.0000000005')).toBe(1_000_000_000)
expect(toAtomicAma('1.9999999999')).toBe(1_999_999_999)
expect(toAtomicAma('0.0000000001')).toBe(0)
expect(toAtomicAma('0.9999999999')).toBe(999_999_999)
})

it('throws instead of returning an amount past the safe-integer ceiling', () => {
expect(() => toAtomicAma(1e10)).toThrow('exceeds the maximum safe integer')
expect(() => toAtomicAma(1e21)).toThrow('exceeds the maximum safe integer')
expect(() => toAtomicAma('21000000')).toThrow('exceeds the maximum safe integer')
expect(toAtomicAma('9007199.254740991')).toBe(Number.MAX_SAFE_INTEGER)
expect(() => toAtomicAma('9007199.254740992')).toThrow(
'exceeds the maximum safe integer'
)
})
})

describe('toAtomicAmaString', () => {
it('agrees with toAtomicAma wherever toAtomicAma can represent the result', () => {
expect(toAtomicAmaString(0)).toBe('0')
expect(toAtomicAmaString(1)).toBe('1000000000')
expect(toAtomicAmaString(1.5)).toBe('1500000000')
expect(toAtomicAmaString(1.00000001)).toBe('1000000010')
expect(toAtomicAmaString('0.000000001')).toBe('1')
expect(toAtomicAmaString('100')).toBe('100000000000')
})

it('stays exact above the safe-integer ceiling', () => {
expect(toAtomicAmaString('21000000')).toBe('21000000000000000')
expect(toAtomicAmaString('9007199.254740992')).toBe('9007199254740992')
expect(toAtomicAmaString(1e21)).toBe('1' + '0'.repeat(30))
expect(toAtomicAmaString('123456789012345678901.987654321')).toBe(
'123456789012345678901987654321'
)
})

it('applies the same validation as toAtomicAma', () => {
expect(() => toAtomicAmaString('abc')).toThrow('Invalid AMA amount')
expect(() => toAtomicAmaString('-1')).toThrow('Invalid AMA amount')
expect(() => toAtomicAmaString(NaN)).toThrow('Invalid AMA amount')
expect(() => toAtomicAmaString(Infinity)).toThrow('Invalid AMA amount')
expect(toAtomicAmaString('1.0000000005')).toBe('1000000000')
})
})

describe('fromAtomicAma', () => {
Expand Down Expand Up @@ -84,6 +154,53 @@ describe('Conversion Utilities', () => {
const tooLarge = Number.MAX_SAFE_INTEGER + 1
expect(() => fromAtomicAma(tooLarge)).toThrow('Value exceeds maximum safe integer')
})

it('rejects strings that are not a whole count of atomic units', () => {
expect(() => fromAtomicAma('abc')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAma('')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAma('1.5')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAma('1000000000abc')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAma('1e9')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAma('-1')).toThrow('Negative value not allowed')
})
})

describe('fromAtomicAmaString', () => {
it('converts atomic values to decimal strings accurately', () => {
expect(fromAtomicAmaString('0')).toBe('0')
expect(fromAtomicAmaString(0)).toBe('0')
expect(fromAtomicAmaString(0n)).toBe('0')
expect(fromAtomicAmaString('1000000000')).toBe('1')
expect(fromAtomicAmaString(1000000000n)).toBe('1')
expect(fromAtomicAmaString('1500000000')).toBe('1.5')
expect(fromAtomicAmaString(1500000000n)).toBe('1.5')
expect(fromAtomicAmaString('1')).toBe('0.000000001')
expect(fromAtomicAmaString('1000000001')).toBe('1.000000001')
})

it('handles arbitrarily large amounts without MAX_SAFE_INTEGER limits', () => {
// 10,000,000 AMA (genesis vaults and large holders)
expect(fromAtomicAmaString('10000000000000000')).toBe('10000000')
expect(fromAtomicAmaString(10000000000000000n)).toBe('10000000')

// 21,000,000 AMA
expect(fromAtomicAmaString('21000000000000000')).toBe('21000000')

// 30-digit atomic string
expect(fromAtomicAmaString('123456789012345678901987654321')).toBe(
'123456789012345678901.987654321'
)
})

it('rejects invalid inputs', () => {
expect(() => fromAtomicAmaString('-1')).toThrow('Negative value not allowed')
expect(() => fromAtomicAmaString(-100n)).toThrow('Negative value not allowed')
expect(() => fromAtomicAmaString('abc')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAmaString('1.5')).toThrow('Invalid atomic amount')
expect(() => fromAtomicAmaString(1.5)).toThrow('Invalid atomic amount')
expect(() => fromAtomicAmaString(NaN)).toThrow('Invalid atomic amount')
expect(() => fromAtomicAmaString(Infinity)).toThrow('Invalid atomic amount')
})
})

describe('Round-trip Conversion', () => {
Expand All @@ -100,5 +217,12 @@ describe('Conversion Utilities', () => {
expect(fromAtomicAma(toAtomicAma(1))).toBe(1)
expect(fromAtomicAma(toAtomicAma(1000))).toBe(1000)
})

it('round-trips lossless strings at extreme scale', () => {
const large = '21000000000000000'
expect(toAtomicAmaString(fromAtomicAmaString(large))).toBe(large)
const human = '123456789012345.123456789'
expect(fromAtomicAmaString(toAtomicAmaString(human))).toBe(human)
})
})
})
63 changes: 62 additions & 1 deletion src/__tests__/encryption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
decryptWithPassword,
generateSalt,
generateIV,
deriveKey
deriveKey,
DEFAULT_PBKDF2_ITERATIONS,
MAX_PBKDF2_ITERATIONS,
MIN_PBKDF2_ITERATIONS
} from '../encryption'
import {
uint8ArrayToBase64,
Expand Down Expand Up @@ -76,6 +79,18 @@ describe('Encryption', () => {

expect(new Uint8Array(enc1)).not.toEqual(new Uint8Array(enc2))
})

it('rejects an iteration count exceeding MAX_PBKDF2_ITERATIONS (DoS protection)', async () => {
await expect(
deriveKey('pw', generateSalt(), MAX_PBKDF2_ITERATIONS + 1)
).rejects.toThrow(/PBKDF2 iterations must be an integer between/)
})

it('rejects an iteration count below MIN_PBKDF2_ITERATIONS', async () => {
await expect(
deriveKey('pw', generateSalt(), MIN_PBKDF2_ITERATIONS - 1)
).rejects.toThrow(/PBKDF2 iterations must be an integer between/)
})
})

describe('encryptWithPassword and decryptWithPassword', () => {
Expand Down Expand Up @@ -144,6 +159,52 @@ describe('Encryption', () => {
})
})

describe('KDF parameters recorded in the payload', () => {
it('records the KDF and iteration count it used (default 100,000)', async () => {
const encrypted = await encryptWithPassword('secret', 'pw')
expect(encrypted.kdf).toBe('PBKDF2-SHA256')
expect(encrypted.iterations).toBe(DEFAULT_PBKDF2_ITERATIONS)
expect(DEFAULT_PBKDF2_ITERATIONS).toBe(100_000)
})

it('round-trips a payload written with an explicit valid iteration count', async () => {
const encrypted = await encryptWithPassword('secret', 'pw', 10_000)
expect(encrypted.iterations).toBe(10_000)
expect(await decryptWithPassword(encrypted, 'pw')).toBe('secret')
})

it('reads a legacy payload that records no iteration count', async () => {
const encrypted = await encryptWithPassword('legacy secret', 'pw', 100_000)
const legacy = {
encryptedData: encrypted.encryptedData,
iv: encrypted.iv,
salt: encrypted.salt
}
expect(await decryptWithPassword(legacy, 'pw')).toBe('legacy secret')
})

it('refuses an unknown KDF instead of reporting a wrong password', async () => {
const encrypted = await encryptWithPassword('secret', 'pw')
await expect(
decryptWithPassword({ ...encrypted, kdf: 'scrypt' }, 'pw')
).rejects.toThrow(/Unsupported key derivation function: scrypt/)
})

it('rejects a payload with excessive iterations (DoS protection)', async () => {
const encrypted = await encryptWithPassword('secret', 'pw')
await expect(
decryptWithPassword({ ...encrypted, iterations: 2_000_000_000 }, 'pw')
).rejects.toThrow(/Invalid or unsafe PBKDF2 iteration count/)
})

it('rejects an iteration count below MIN_PBKDF2_ITERATIONS', async () => {
const encrypted = await encryptWithPassword('secret', 'pw')
await expect(
decryptWithPassword({ ...encrypted, iterations: 100 }, 'pw')
).rejects.toThrow(/Invalid or unsafe PBKDF2 iteration count/)
})
})

describe('Base64 utilities', () => {
it('converts Uint8Array to Base64 and back', () => {
const original = new Uint8Array([1, 2, 3, 255, 128, 64])
Expand Down
71 changes: 71 additions & 0 deletions src/__tests__/signing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest'

import { generatePrivateKey, seed64ToKeypair } from '../crypto'
import { toBase58 } from '../encoding'
import { buildUnsigned, normalizeSignerSk, signUnsigned } from '../signing'

const ARGS = ['destination', '1000000000', 'AMA']

describe('signing', () => {
// Date.now() only has millisecond resolution, so two transactions built inside
// the same millisecond used to receive the same nonce — and with it the same
// encoded bytes, the same hash and the same txHash, which the chain cannot
// tell apart from a resubmission of the first transaction.
describe('nonce generation', () => {
it('never issues the same nonce twice inside one millisecond', () => {
const [pk] = seed64ToKeypair(generatePrivateKey())
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
try {
const a = buildUnsigned(pk, 'Coin', 'transfer', ARGS)
const b = buildUnsigned(pk, 'Coin', 'transfer', ARGS)
expect(b.tx.nonce > a.tx.nonce).toBe(true)
expect(Buffer.from(b.hash)).not.toEqual(Buffer.from(a.hash))
} finally {
clock.mockRestore()
}
})

it('keeps nonces increasing when the clock steps backwards', () => {
const [pk] = seed64ToKeypair(generatePrivateKey())
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
try {
const a = buildUnsigned(pk, 'Coin', 'transfer', ARGS)
clock.mockReturnValue(1_600_000_000_000)
const b = buildUnsigned(pk, 'Coin', 'transfer', ARGS)
expect(b.tx.nonce > a.tx.nonce).toBe(true)
} finally {
clock.mockRestore()
}
})
})

describe('normalizeSignerSk', () => {
it('treats a 64-byte seed the same whether it arrives as Base58 or as bytes', () => {
const seed64 = generatePrivateKey()
const [pk, sk32] = seed64ToKeypair(seed64)

expect(normalizeSignerSk(toBase58(seed64))).toEqual(sk32)
// The byte form previously reached bls.sign unreduced, which rejected it
// with "invalid private key: expected ui8a of size 32" — even though
// generatePrivateKey() returns exactly these 64 bytes.
expect(normalizeSignerSk(seed64)).toEqual(sk32)

const unsigned = buildUnsigned(pk, 'Coin', 'transfer', ARGS)
const viaBase58 = signUnsigned(unsigned, toBase58(seed64))
const viaBytes = signUnsigned(unsigned, seed64)
expect(viaBytes.txPacked).toEqual(viaBase58.txPacked)
expect(viaBytes.txHash).toBe(viaBase58.txHash)
})

it('passes an already-derived 32-byte scalar through unchanged', () => {
const [, sk32] = seed64ToKeypair(generatePrivateKey())
expect(normalizeSignerSk(sk32)).toBe(sk32)
})

it('rejects a byte length that cannot be a key', () => {
expect(() => normalizeSignerSk(new Uint8Array(7))).toThrow(
/expected 32-byte scalar or 64-byte seed, got 7 bytes/
)
})
})
})
10 changes: 5 additions & 5 deletions src/contracts/coin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
* The Coin contract is a built-in native contract with no WASM ABI file.
* It differs from WASM contracts:
* - First arg (recipient) is raw binary Uint8Array from fromBase58(), not a UTF-8 string
* - Amount requires toAtomicAma() conversion
* - Amount requires lossless toAtomicAmaString() conversion
*
* This module provides a dedicated builder that handles these special encodings
* while returning a standard ContractCall for uniform consumption by TransactionBuilder.
*/

import { toAtomicAma } from '../conversion'
import { toAtomicAmaString } from '../conversion'
import { fromBase58 } from '../encoding'
import type { ContractCall } from './contract-call'

Expand All @@ -31,13 +31,13 @@ export interface CoinTransferParams {
*
* Handles the special encoding requirements of the Coin contract:
* - recipient is decoded from Base58 to raw Uint8Array
* - amount is converted to atomic units via toAtomicAma()
* - amount is converted to atomic units via toAtomicAmaString() avoiding the MAX_SAFE_INTEGER ceiling
*
* @example
* ```ts
* const call = buildCoinTransfer({
* recipient: '5Kd3N...',
* amount: 10.5,
* amount: '10000000',
* symbol: 'AMA'
* })
* TransactionBuilder.signCall(privateKey, call)
Expand All @@ -47,6 +47,6 @@ export function buildCoinTransfer(params: CoinTransferParams): ContractCall {
return {
contract: 'Coin',
method: 'transfer',
args: [fromBase58(params.recipient), toAtomicAma(params.amount).toString(), params.symbol]
args: [fromBase58(params.recipient), toAtomicAmaString(params.amount), params.symbol]
}
}
7 changes: 4 additions & 3 deletions src/contracts/lockup-prime/parsers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { NetworkType } from '../../networks'
import { parseStateNumber } from '../../contract-state'
import { fromAtomicAma } from '../../conversion'
import { fromAtomicAmaString } from '../../conversion'

import { LockupPrime } from './helpers'
import type { LockupPrimeVault, RawLockupPrimeVaultData } from './types'
Expand Down Expand Up @@ -52,7 +52,8 @@ export function parseVaultData(

const { tier, multiplier, unlockEpoch, amountFlat } = rawData

const amount = fromAtomicAma(amountFlat)
// Lossless string conversion prevents silent null returns on large vaults
const amount = fromAtomicAmaString(amountFlat)

const tierDef = LockupPrime.getTier(tier, network)
const lockEpoch = tierDef ? unlockEpoch - tierDef.epochs : unlockEpoch
Expand All @@ -62,7 +63,7 @@ export function parseVaultData(
multiplier,
unlockEpoch,
lockEpoch,
amount,
amount: amount as unknown as number,
vaultIndex
}
} catch {
Expand Down
Loading