diff --git a/src/app/[lang]/txn/compose/components/ComposeForm.test.tsx b/src/app/[lang]/txn/compose/components/ComposeForm.test.tsx index b65a6846..56705a90 100644 --- a/src/app/[lang]/txn/compose/components/ComposeForm.test.tsx +++ b/src/app/[lang]/txn/compose/components/ComposeForm.test.tsx @@ -49,6 +49,7 @@ describe('Compose Form Component', () => { it('has base transaction fields (using suggested parameters)', async () => { render(); + expect(await screen.findByText('fields.type.label')).toBeInTheDocument(); expect(screen.getByText('fields.snd.label')).toBeInTheDocument(); expect(screen.getByText('fields.use_sug_fee.label')).toBeInTheDocument(); @@ -59,10 +60,12 @@ describe('Compose Form Component', () => { expect(screen.queryByText('fields.lv.label')).not.toBeInTheDocument(); expect(screen.getByText('fields.lx.label')).toBeInTheDocument(); expect(screen.getByText('fields.rekey.label')).toBeInTheDocument(); + expect(screen.getAllByText('fields.base64.label')).toHaveLength(2); }); it('has base transaction fields (not using suggested parameters)', async () => { render(); + expect(await screen.findByText('fields.type.label')).toBeInTheDocument(); expect(screen.getByText('fields.snd.label')).toBeInTheDocument(); @@ -78,10 +81,10 @@ describe('Compose Form Component', () => { expect(useSugRoundsToggle).not.toBeChecked(); expect(screen.getByText('fields.fv.label')).toBeInTheDocument(); expect(screen.getByText('fields.lv.label')).toBeInTheDocument(); - expect(screen.getByText('fields.note.label')).toBeInTheDocument(); expect(screen.getByText('fields.lx.label')).toBeInTheDocument(); expect(screen.getByText('fields.rekey.label')).toBeInTheDocument(); + expect(screen.getAllByText('fields.base64.label')).toHaveLength(2); }); it('has fields for payment transaction type if "Payment" transaction type is selected', @@ -177,6 +180,7 @@ describe('Compose Form Component', () => { expect(screen.queryByText('fields.apar_r_use_snd.label')).not.toBeInTheDocument(); expect(screen.queryByText('fields.apar_r.label')).not.toBeInTheDocument(); expect(screen.queryByText('fields.apar_am.label')).not.toBeInTheDocument(); + expect(screen.getAllByText('fields.base64.label')).toHaveLength(2); await userEvent.selectOptions(screen.getByLabelText(/fields.type.label/), 'acfg'); @@ -196,6 +200,7 @@ describe('Compose Form Component', () => { expect(screen.getByText('fields.apar_r_use_snd.label')).toBeInTheDocument(); expect(screen.queryByText('fields.apar_r.label')).not.toBeInTheDocument(); expect(screen.getByText('fields.apar_am.label')).toBeInTheDocument(); + expect(screen.getAllByText('fields.base64.label')).toHaveLength(3); }); // eslint-disable-next-line max-len @@ -219,6 +224,7 @@ describe('Compose Form Component', () => { expect(screen.queryByText('fields.apar_r_use_snd.label')).not.toBeInTheDocument(); expect(screen.queryByText('fields.apar_r.label')).not.toBeInTheDocument(); expect(screen.queryByText('fields.apar_am.label')).not.toBeInTheDocument(); + expect(screen.getAllByText('fields.base64.label')).toHaveLength(2); await userEvent.selectOptions(screen.getByLabelText(/fields.type.label/), 'acfg'); @@ -230,6 +236,7 @@ describe('Compose Form Component', () => { expect(screen.getByText('fields.apar_df.label')).toBeInTheDocument(); expect(screen.getByText('fields.apar_au.label')).toBeInTheDocument(); expect(screen.getByText('fields.apar_am.label')).toBeInTheDocument(); + expect(screen.getAllByText('fields.base64.label')).toHaveLength(3); // Turn off using sender for manager address const aparMSndToggle = screen.getByLabelText('fields.apar_m_use_snd.label'); diff --git a/src/app/[lang]/txn/compose/components/ComposeSubmitButton.test.tsx b/src/app/[lang]/txn/compose/components/ComposeSubmitButton.test.tsx index aec5dfce..0dcd5759 100644 --- a/src/app/[lang]/txn/compose/components/ComposeSubmitButton.test.tsx +++ b/src/app/[lang]/txn/compose/components/ComposeSubmitButton.test.tsx @@ -53,6 +53,51 @@ describe('Compose Form Component', () => { expect(routerPushMock).toHaveBeenCalled(); }); + it('can store submitted transaction data with Base64 note and lease', async () => { + render( + // Wrap component in new Jotai provider to reset data stored in Jotai atoms + + ); + + // Enter data + await userEvent.selectOptions(screen.getByLabelText(/fields.type.label/), 'pay'); + await userEvent.click(screen.getByLabelText(/fields.snd.label/)); + await userEvent.paste('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + await userEvent.click(screen.getByLabelText(/fields.rcv.label/)); + await userEvent.paste('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + await userEvent.click(screen.getByLabelText(/fields.amt.label/)); + await userEvent.paste('5'); + + const b64Checkboxes = screen.getAllByLabelText('fields.base64.label'); + // Enter note as Base64 + await userEvent.click(b64Checkboxes[0]); // Enable base64 for note + await userEvent.click(screen.getByLabelText(/fields.note.label/)); + await userEvent.paste('SGVsbG8gd29ybGQh'); + // Enter lease as Base64 + await userEvent.click(b64Checkboxes[1]); // Enable base64 for note + await userEvent.click(screen.getByLabelText(/fields.lx.label/)); + await userEvent.paste('SSB0aGluaywgdGhlcmVmb3JlIEkgYW0uIEZvb2Jhcg=='); + + // Submit data + await userEvent.click(screen.getByText('sign_txn_btn')); + + // Check session storage + expect(JSON.parse(sessionStorage.getItem('txnData') || '{}')).toStrictEqual({ + txn: { + type: 'pay', + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + rcv: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + amt: 5, + note: 'SGVsbG8gd29ybGQh', + lx: 'SSB0aGluaywgdGhlcmVmb3JlIEkgYW0uIEZvb2Jhcg==', + }, + useSugFee: true, + useSugRounds: true, + b64Note: true, + b64Lx: true, + }); + }); + it('can store submitted *payment* transaction data', async () => { render( // Wrap component in new Jotai provider to reset data stored in Jotai atoms @@ -81,6 +126,8 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, }); }); @@ -115,10 +162,74 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, retrievedAssetInfo: { name: 'Foo Token', unitName: 'FOO', total: 1000, decimals: 2 }, }); }); + it('can store submitted *asset configuration* transaction data with Base64 metadata hash', + async () => { + render( + // Wrap component in new Jotai provider to reset data stored in Jotai atoms + + ); + + // Enter data + await userEvent.selectOptions(screen.getByLabelText(/fields.type.label/), 'acfg'); + + await userEvent.click(screen.getByLabelText(/fields.snd.label/)); + await userEvent.paste('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + await userEvent.click(screen.getByLabelText(/fields.apar_un.label/)); + await userEvent.paste('FAKE'); + await userEvent.click(screen.getByLabelText(/fields.apar_an.label/)); + await userEvent.paste('Fake Token'); + await userEvent.click(screen.getByLabelText(/fields.apar_t.label/)); + await userEvent.paste('10000000'); + await userEvent.click(screen.getByLabelText(/fields.apar_dc.label/)); + await userEvent.paste('3'); + await userEvent.click(screen.getByLabelText(/fields.apar_df.label/)); + await userEvent.click(screen.getByLabelText(/fields.apar_au.label/)); + await userEvent.paste('https://fake.token'); + + const b64Checkboxes = screen.getAllByLabelText('fields.base64.label'); + // Enter metadata hash as Base64 + await userEvent.click(b64Checkboxes[1]); // Enable base64 for metadata hash + await userEvent.click(screen.getByLabelText(/fields.apar_am.label/)); + await userEvent.paste('VGhpcyBpcyBhIHZhbGlkIGhhc2ghISEhISEhISEhISE='); + + // Submit data + await userEvent.click(screen.getByText('sign_txn_btn')); + + // Check session storage + expect(JSON.parse(sessionStorage.getItem('txnData') || '{}')).toStrictEqual({ + txn: { + type: 'acfg', + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_un: 'FAKE', + apar_an: 'Fake Token', + apar_t: '10000000', + apar_dc: 3, + apar_df: true, + apar_au: 'https://fake.token', + apar_m: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_f: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_c: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_r: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_am: 'VGhpcyBpcyBhIHZhbGlkIGhhc2ghISEhISEhISEhISE=', + }, + useSugFee: true, + useSugRounds: true, + b64Note: false, + b64Lx: false, + b64Apar_am: true, + apar_mUseSnd: true, + apar_fUseSnd: true, + apar_cUseSnd: true, + apar_rUseSnd: true, + }); + }, 10000); + // eslint-disable-next-line max-len it('can store submitted *asset configuration* transaction data (with asset addresses set to sender)', async () => { @@ -168,10 +279,13 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, apar_mUseSnd: true, apar_fUseSnd: true, apar_cUseSnd: true, apar_rUseSnd: true, + b64Apar_am: false, }); }, 10000); @@ -235,10 +349,13 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, apar_mUseSnd: false, apar_fUseSnd: false, apar_cUseSnd: false, apar_rUseSnd: false, + b64Apar_am: false, }); }, 10000); @@ -286,6 +403,8 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, retrievedAssetInfo: {name: 'Foo Token', unitName: 'FOO', total: 1000, decimals: 2 }, }); }, 10000); @@ -320,6 +439,8 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, retrievedAssetInfo: { name: 'Foo Token', unitName: 'FOO', total: 1000, decimals: 2 }, }); }); @@ -368,6 +489,8 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, }); }); @@ -381,7 +504,6 @@ describe('Compose Form Component', () => { await userEvent.selectOptions(screen.getByLabelText(/fields.type.label/), 'appl'); await userEvent.click(screen.getByLabelText(/fields.snd.label/)); await userEvent.paste('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); - await userEvent.click(screen.getByLabelText(/fields.apap.label/)); await userEvent.paste('BYEB'); await userEvent.click(screen.getByLabelText(/fields.apsu.label/)); @@ -464,6 +586,8 @@ describe('Compose Form Component', () => { }, useSugFee: true, useSugRounds: true, + b64Note: false, + b64Lx: false, }); }, 10000); @@ -482,6 +606,8 @@ describe('Compose Form Component', () => { }, useSugFee: false, useSugRounds: false, + b64Note: false, + b64Lx: false, })); render( // Wrap component in new Jotai provider to reset data stored in Jotai atoms @@ -493,6 +619,8 @@ describe('Compose Form Component', () => { use_sug_fee: false, fee: 0.001, use_sug_rounds: false, + b64_note: false, + b64_lx: false, fv: 5, lv: 1005, rekey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', @@ -529,8 +657,48 @@ describe('Compose Form Component', () => { rekey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', rcv: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', amt: 42, + b64_note: false, + b64_lx: false }); }); + it('can retrieve transaction data from session storage with Base64 note and lease', + async () => { + sessionStorage.setItem('txnData', JSON.stringify({ + txn: { + type: 'pay', + snd: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + fee: 0.001, // This should be ignored + fv: 5, // This should be ignored + lv: 1005, // This should be ignored + note: 'SGVsbG8gd29ybGQh', + rekey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + rcv: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amt: 42, + lx: 'VGhpcyBpcyBhIGxlYXNl', + }, + useSugFee: true, + useSugRounds: true, + b64Note: true, + b64Lx: true, + })); + render( + // Wrap component in new Jotai provider to reset data stored in Jotai atoms + + ); + expect(await screen.findByRole('form')).toHaveFormValues({ + type: 'pay', + snd: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + note: 'SGVsbG8gd29ybGQh', + use_sug_fee: true, + use_sug_rounds: true, + b64_note: true, + rekey: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + rcv: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amt: 42, + lx: 'VGhpcyBpcyBhIGxlYXNl', + b64_lx: true + }); + }); }); diff --git a/src/app/[lang]/txn/compose/components/fields/AssetConfigFields/MetadataHash.tsx b/src/app/[lang]/txn/compose/components/fields/AssetConfigFields/MetadataHash.tsx index 0149e5a0..f2a1163a 100644 --- a/src/app/[lang]/txn/compose/components/fields/AssetConfigFields/MetadataHash.tsx +++ b/src/app/[lang]/txn/compose/components/fields/AssetConfigFields/MetadataHash.tsx @@ -1,8 +1,11 @@ -import { TextField } from '@/app/[lang]/components/form'; +import { CheckboxField, FieldGroup, TextField } from '@/app/[lang]/components/form'; import { type TFunction } from 'i18next'; import { useAtomValue } from 'jotai'; import { + B64_METADATA_HASH_LENGTH, METADATA_HASH_LENGTH, + aparAmConditionalBase64Atom, + aparAmConditionalLengthAtom, assetConfigFormControlAtom, showFormErrorsAtom, tipBtnClass, @@ -11,8 +14,19 @@ import { import FieldErrorMessage from '../FieldErrorMessage'; export default function MetadataHash({ t }: { t: TFunction }) { + return ( + + + + + ); +} + +export function MetadataHashInput({ t }: { t: TFunction }) { const form = useAtomValue(assetConfigFormControlAtom); const showFormErrors = useAtomValue(showFormErrorsAtom); + const aparAmCondLengthGroup = useAtomValue(aparAmConditionalLengthAtom); + const aparAmCondB64Group = useAtomValue(aparAmConditionalBase64Atom); // If creation transaction return (!form.values.caid && <> form.handleOnChange('apar_am')(e.target.value)} onFocus={form.handleOnFocus('apar_am')} @@ -42,5 +60,40 @@ export default function MetadataHash({ t }: { t: TFunction }) { dict={form.fieldErrors.apar_am.message.dict} /> } + {(showFormErrors || form.touched.apar_am) && !aparAmCondLengthGroup.isValid + && aparAmCondLengthGroup.error && + + } + {(showFormErrors || form.touched.apar_am) && !aparAmCondB64Group.isValid + && aparAmCondB64Group.error && + + } ); } + +export function Base64Input({ t }: { t: TFunction }) { + const form = useAtomValue(assetConfigFormControlAtom); + return ( + form.handleOnChange('b64Apar_am')(e.target.checked)} + /> + ); +} diff --git a/src/app/[lang]/txn/compose/components/fields/GeneralFields/Lease.tsx b/src/app/[lang]/txn/compose/components/fields/GeneralFields/Lease.tsx index 6bd2f7c1..452a62b0 100644 --- a/src/app/[lang]/txn/compose/components/fields/GeneralFields/Lease.tsx +++ b/src/app/[lang]/txn/compose/components/fields/GeneralFields/Lease.tsx @@ -1,18 +1,32 @@ -import { TextField } from '@/app/[lang]/components/form'; +import { CheckboxField, FieldGroup, TextField } from '@/app/[lang]/components/form'; import { type TFunction } from 'i18next'; import { useAtomValue } from 'jotai'; import { - LEASE_MAX_LENGTH, + B64_LEASE_LENGTH, + LEASE_LENGTH, generalFormControlAtom, showFormErrorsAtom, tipContentClass, tipBtnClass, + lxConditionalLengthAtom, + lxConditionalBase64Atom, } from '@/app/lib/txn-data'; import FieldErrorMessage from '../FieldErrorMessage'; export default function Lease({ t }: { t: TFunction }) { + return ( + + + + + ); +} + +export function LeaseInput({ t }: { t: TFunction }) { const form = useAtomValue(generalFormControlAtom); const showFormErrors = useAtomValue(showFormErrorsAtom); + const lxCondLengthGroup = useAtomValue(lxConditionalLengthAtom); + const lxCondB64Group = useAtomValue(lxConditionalBase64Atom); return (<> form.handleOnChange('lx')(e.target.value)} onFocus={form.handleOnFocus('lx')} @@ -39,5 +59,38 @@ export default function Lease({ t }: { t: TFunction }) { dict={form.fieldErrors.lx.message.dict} /> } + {(showFormErrors || form.touched.lx) && !lxCondLengthGroup.isValid && lxCondLengthGroup.error && + + } + {(showFormErrors || form.touched.lx) && !lxCondB64Group.isValid && lxCondB64Group.error && + + } ); } + +export function Base64Input({ t }: { t: TFunction }) { + const form = useAtomValue(generalFormControlAtom); + return ( + form.handleOnChange('b64Lx')(e.target.checked)} + /> + ); +} diff --git a/src/app/[lang]/txn/compose/components/fields/GeneralFields/Note.tsx b/src/app/[lang]/txn/compose/components/fields/GeneralFields/Note.tsx index 00edc95b..0ff0252d 100644 --- a/src/app/[lang]/txn/compose/components/fields/GeneralFields/Note.tsx +++ b/src/app/[lang]/txn/compose/components/fields/GeneralFields/Note.tsx @@ -1,18 +1,32 @@ -import { TextAreaField } from '@/app/[lang]/components/form'; +import { CheckboxField, FieldGroup, TextAreaField } from '@/app/[lang]/components/form'; import { type TFunction } from 'i18next'; import { useAtomValue } from 'jotai'; import { + B64_NOTE_MAX_LENGTH, NOTE_MAX_LENGTH, generalFormControlAtom, showFormErrorsAtom, tipContentClass, tipBtnClass, + noteConditionalMaxAtom, + noteConditionalBase64Atom, } from '@/app/lib/txn-data'; import FieldErrorMessage from '../FieldErrorMessage'; export default function Note({ t }: { t: TFunction }) { + return ( + + + + + ); +} + +export function NoteInput({ t }: { t: TFunction }) { const form = useAtomValue(generalFormControlAtom); const showFormErrors = useAtomValue(showFormErrorsAtom); + const noteCondMaxGroup = useAtomValue(noteConditionalMaxAtom); + const noteCondB64Group = useAtomValue(noteConditionalBase64Atom); return (<> form.handleOnChange('note')(e.target.value)} onFocus={form.handleOnFocus('note')} @@ -42,5 +62,38 @@ export default function Note({ t }: { t: TFunction }) { dict={form.fieldErrors.note.message.dict} /> } + {(showFormErrors || form.touched.note) && !noteCondMaxGroup.isValid && noteCondMaxGroup.error && + + } + {(showFormErrors || form.touched.note) && !noteCondB64Group.isValid && noteCondB64Group.error && + + } ); } + +export function Base64Input({ t }: { t: TFunction }) { + const form = useAtomValue(generalFormControlAtom); + return ( + form.handleOnChange('b64Note')(e.target.checked)} + /> + ); +} diff --git a/src/app/[lang]/txn/sign/components/SignTxn.tsx b/src/app/[lang]/txn/sign/components/SignTxn.tsx index c708fd19..7cbb7c37 100644 --- a/src/app/[lang]/txn/sign/components/SignTxn.tsx +++ b/src/app/[lang]/txn/sign/components/SignTxn.tsx @@ -18,13 +18,15 @@ import { getActiveProvider, } from '@/app/lib/wallet-utils'; import { nodeConfigAtom } from '@/app/lib/node-config'; -import { createTxnFromData, storedSignedTxnAtom, storedTxnDataAtom } from '@/app/lib/txn-data'; -import { - fee as feeAtom, - fv as firstRoundAtom, - lv as lastRoundAtom -} from '@/app/lib/txn-data/atoms'; import { bytesToBase64DataUrl, dataUrlToBytes } from '@/app/lib/utils'; +import { + AssetConfigTxnData, + StoredTxnData, + createTxnFromData, + storedSignedTxnAtom, + storedTxnDataAtom, + txnDataAtoms, +} from '@/app/lib/txn-data'; import NextStepButton from './NextStepButton'; type Props = { @@ -37,9 +39,9 @@ export default function SignTxn({ lng }: Props) { const { t } = useTranslation(lng || '', ['app', 'common', 'sign_txn']); const currentURLParams = useSearchParams(); const nodeConfig = useAtomValue(nodeConfigAtom); - const setFee = useSetAtom(feeAtom); - const setFirstRound = useSetAtom(firstRoundAtom); - const setLastRound = useSetAtom(lastRoundAtom); + const setFee = useSetAtom(txnDataAtoms.fee); + const setFirstRound = useSetAtom(txnDataAtoms.fv); + const setLastRound = useSetAtom(txnDataAtoms.lv); const storedTxnData = useAtomValue(storedTxnDataAtom); const [storedSignedTxn, setStoredSignedTxn] = useAtom(storedSignedTxnAtom); @@ -65,19 +67,53 @@ export default function SignTxn({ lng }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodeConfig, storedTxnData]); + /** Decode all of the properties encoded in Base64 in the given transaction data object into byte + * arrays + * @param txnData Transaction data with the Base64 encoded properties to decode + * @returns Transaction data with the Base64 encoded properties decoded into byte arrays + * (Uint8Array) + */ + const decodeBase64TxnDataProps = async (txnData: StoredTxnData) => { + const newTxnData: StoredTxnData = {...txnData}; + + // Convert Base64 note to byte array + if (txnData.b64Note && txnData.txn.note) { + newTxnData.txn.note = await dataUrlToBytes( + `data:application/octet-stream;base64,${txnData.txn.note}` + ); + } + + // Convert Base64 lease to byte array + if (txnData.b64Lx && txnData.txn.lx) { + newTxnData.txn.lx = await dataUrlToBytes( + `data:application/octet-stream;base64,${txnData.txn.lx}` + ); + } + + // Convert Base64 metadata hash to byte array + if (txnData.b64Apar_am && (newTxnData.txn as AssetConfigTxnData).apar_am) { + (newTxnData.txn as AssetConfigTxnData).apar_am = await dataUrlToBytes( + `data:application/octet-stream;base64,${(newTxnData.txn as AssetConfigTxnData).apar_am}` + ); + } + + return newTxnData; + }; + /** Create transaction object from stored transaction data and sign the transaction */ const signTransaction = async () => { if (!storedTxnData) throw Error('No transaction data exists in session storage'); const suggestedParams = await getSuggestedParams; - const unsignedTxnData = storedTxnData.txn; + const unsignedTxnData = {...storedTxnData.txn}; let unsignedTxn = new Uint8Array; // Set fee to suggested fee if suggested fee is to be used if (storedTxnData.useSugFee) unsignedTxnData.fee = suggestedParams.fee; + // Set first & last valid rounds to suggested first & last rounds if suggested rounds are to be + // used if (storedTxnData.useSugRounds) { - // Set first & last valid rounds to suggested first & last rounds unsignedTxnData.fv = suggestedParams.firstRound; unsignedTxnData.lv = suggestedParams.lastRound; } @@ -86,7 +122,7 @@ export default function SignTxn({ lng }: Props) { // Create Transaction object and encoded it unsignedTxn = algosdk.encodeUnsignedTransaction( createTxnFromData( - unsignedTxnData, + (await decodeBase64TxnDataProps({...storedTxnData, txn: unsignedTxnData})).txn, suggestedParams.genesisID, suggestedParams.genesisHash, !storedTxnData.useSugFee // Enable/disable flat fee @@ -99,20 +135,30 @@ export default function SignTxn({ lng }: Props) { // Sign the transaction and store it const signedTxn = (await signTransactions([unsignedTxn]))[0]; - const signedTxnBase64 = await bytesToBase64DataUrl(signedTxn); - setStoredSignedTxn(signedTxnBase64); + const signedTxnDataUrl = await bytesToBase64DataUrl(signedTxn); + setStoredSignedTxn(signedTxnDataUrl); }; useEffect(() => { if (!storedTxnData) return; - getSuggestedParams.then(({genesisID, genesisHash, fee: feePerByte, firstRound, lastRound }) => { + /* + * Check if transaction data has been changed after it was signed. At the same time, get the + * current suggested parameters (valid rounds & fee-per-byte) if they are to be used. + */ + const checkSignedTxn = async () => { + const { + genesisID, + genesisHash, + fee: feePerByte, + firstRound, + lastRound + } = await getSuggestedParams; const unsignedTxnData = {...storedTxnData.txn}; // Copy stored transaction data let unsignedTxn: algosdk.Transaction|null = null; // If the suggested first & valid rounds are to be used, set first & valid rounds to suggested - // first & valid rounds. Do this before suggested fee is potentially when a algosdk - // `Transaction` object is created. + // first & valid rounds. if (storedTxnData.useSugRounds) { unsignedTxnData.fv = firstRound; setFirstRound(firstRound); @@ -125,34 +171,46 @@ export default function SignTxn({ lng }: Props) { // flat fee) if (storedTxnData.useSugFee) { unsignedTxnData.fee = microalgosToAlgos(feePerByte); - unsignedTxn = createTxnFromData(unsignedTxnData, genesisID, genesisHash, false); + unsignedTxn = createTxnFromData( + (await decodeBase64TxnDataProps({...storedTxnData, txn: unsignedTxnData})).txn, + genesisID, + genesisHash, + false + ); setFee(microalgosToAlgos(unsignedTxn.fee)); } if (storedSignedTxn) { - // Check if transaction data has been changed after it was signed. If the data has changed, - // remove the stored signed transaction. - dataUrlToBytes(storedSignedTxn).then((signedTxnBytes) => { - let signedTxn: algosdk.Transaction; + // Remove the stored signed transaction if the the unsigned transaction data does not match + // the stored signed transaction data, indicating that the transaction data was changed + // after it was signed. + const signedTxnBytes = await dataUrlToBytes(storedSignedTxn); + let signedTxn: algosdk.Transaction; - try { - signedTxn = algosdk.decodeSignedTransaction(signedTxnBytes).txn; - } catch (e) { // The stored signed transaction is invalid for some reason - setStoredSignedTxn(RESET); // The transaction will need to be signed again - return; - } + try { + signedTxn = algosdk.decodeSignedTransaction(signedTxnBytes).txn; + } catch (e) { // The stored signed transaction is invalid for some reason + setStoredSignedTxn(RESET); // The transaction will need to be signed again + return; + } - // Create unsigned transaction if one was not already created when calculating the - // suggested fee - if (unsignedTxn === null) { - unsignedTxn = createTxnFromData(unsignedTxnData, genesisID, genesisHash); - } + // Create unsigned transaction if one was not already created when calculating the + // suggested fee + if (unsignedTxn === null) { + unsignedTxn = createTxnFromData( + (await decodeBase64TxnDataProps({...storedTxnData, txn: unsignedTxnData})).txn, + genesisID, + genesisHash + ); + } - // The transaction has been changed and will need to be signed again - if (unsignedTxn.txID() !== signedTxn.txID()) setStoredSignedTxn(RESET); - }); + // The transaction has been changed and will need to be signed again + if (unsignedTxn.txID() !== signedTxn.txID()) setStoredSignedTxn(RESET); } - }); + }; + + checkSignedTxn(); + /* * NOTE: The node configuration is added as a dependency because the transaction may need to be * signed again if it is for a different network. diff --git a/src/app/[lang]/txn/sign/components/TxnDataTable.test.tsx b/src/app/[lang]/txn/sign/components/TxnDataTable.test.tsx index 2fc9e097..6d9a4c4f 100644 --- a/src/app/[lang]/txn/sign/components/TxnDataTable.test.tsx +++ b/src/app/[lang]/txn/sign/components/TxnDataTable.test.tsx @@ -112,6 +112,15 @@ describe('Transaction Data Table Component', () => { expect(screen.getByText('none')).toBeInTheDocument(); }); + it('indicates when note is Base64 encoded', () => { + sessionStorage.setItem('txnData', JSON.stringify({ + txn: { note: 'SGVsbG8gd29ybGQ=' }, + b64Note: true, + })); + render(); + expect(screen.getByText(/fields.base64.with_label/)).toBeInTheDocument(); + }); + it('displays "none" when there is no lease', () => { sessionStorage.setItem('txnData', JSON.stringify({ txn: { @@ -124,6 +133,15 @@ describe('Transaction Data Table Component', () => { expect(screen.getByText('none')).toBeInTheDocument(); }); + it('indicates when lease is Base64 encoded', () => { + sessionStorage.setItem('txnData', JSON.stringify({ + txn: { lx: 'SGVsbG8gd29ybGQ=' }, + b64Lx: true, + })); + render(); + expect(screen.getByText(/fields.base64.with_label/)).toBeInTheDocument(); + }); + it('displays "none" when there is no rekey address', () => { sessionStorage.setItem('txnData', JSON.stringify({ txn: { @@ -407,6 +425,18 @@ describe('Transaction Data Table Component', () => { expect(screen.getByText('none')).toBeInTheDocument(); }); + it('indicates when metadata hash is Base64 encoded', () => { + sessionStorage.setItem('txnData', JSON.stringify({ + txn: { + type: 'acfg', + apar_am: 'VGhpcyBpcyBhIHZhbGlkIGhhc2ghISEhISEhISEhISE=' + }, + b64Apar_am: true, + })); + render(); + expect(screen.getByText(/fields.base64.with_label/)).toBeInTheDocument(); + }); + }); describe('Asset Freeze Transaction', () => { diff --git a/src/app/[lang]/txn/sign/components/TxnDataTable.tsx b/src/app/[lang]/txn/sign/components/TxnDataTable.tsx index 2c246751..b6fad231 100644 --- a/src/app/[lang]/txn/sign/components/TxnDataTable.tsx +++ b/src/app/[lang]/txn/sign/components/TxnDataTable.tsx @@ -492,8 +492,14 @@ export default function TxnDataTable({ lng }: Props) { - {t('fields.note.label')} - {storedTxnData + + {storedTxnData?.b64Note + ? t('fields.base64.with_label', { label: t('fields.note.label') }) + : t('fields.note.label') + } + + + {storedTxnData ? (storedTxnData?.txn?.note || {t('none')}) : t('loading') } @@ -533,7 +539,12 @@ export default function TxnDataTable({ lng }: Props) { { // If an asset creation transaction !((storedTxnData?.txn as TxnData.AssetConfigTxnData)?.caid) && <> - {t('fields.apar_am.label')} + + {storedTxnData?.b64Apar_am + ? t('fields.base64.with_label', { label: t('fields.apar_am.label') }) + : t('fields.apar_am.label') + } + {((storedTxnData?.txn as TxnData.AssetConfigTxnData)?.apar_am) || {t('none')}} @@ -593,11 +604,18 @@ export default function TxnDataTable({ lng }: Props) { - {t('fields.lx.label')} - {storedTxnData - ? (storedTxnData?.txn?.lx || {t('none')}) - : t('loading') - } + + {storedTxnData?.b64Lx + ? t('fields.base64.with_label', { label: t('fields.lx.label') }) + : t('fields.lx.label') + } + + + {storedTxnData + ? (storedTxnData?.txn?.lx || {t('none')}) + : t('loading') + } + {t('fields.rekey.label')} diff --git a/src/app/i18n/locales/en/compose_txn.yml b/src/app/i18n/locales/en/compose_txn.yml index e6e4e013..8b8a028a 100644 --- a/src/app/i18n/locales/en/compose_txn.yml +++ b/src/app/i18n/locales/en/compose_txn.yml @@ -62,6 +62,7 @@ fields: tip: > Data to attach to this transaction. Examples: message in text, JSON data, byte data placeholder: Enter note… + placeholder_b64: Enter note as Base64 text… use_sug_rounds: # Use suggested first & last rounds? label: Automatically set valid rounds tip: > @@ -268,15 +269,15 @@ fields: # Fields for key registration type votekey: # Voting key label: Voting key - tip: The root participation public key encoded in base64. + tip: The root participation public key encoded in Base64. placeholder: Voting key selkey: # Selection key label: Selection key - tip: The VRF public key encoded in base64. + tip: The VRF public key encoded in Base64. placeholder: Selection key sprfkey: # State proof key label: State proof key - tip: The 64 byte state proof public key commitment encoded in base64. + tip: The 64 byte state proof public key commitment encoded in Base64. placeholder: State proof key votefst: # First voting round label: First voting round @@ -324,14 +325,14 @@ fields: Logic executed for every application transaction, except when the application action type is set to “clear state”. It can read and write global state for the application, as well as account-specific local state. Approval programs may reject the transaction. - placeholder: Enter compiled approval program as base64 text + placeholder: Enter compiled approval program as Base64 text apsu: # Clear-state program label: Clear-state program tip: > Logic executed for application transactions with the application action type set to “clear state”. It can read and write global state for the application, as well as account-specific local state. Clear-state programs cannot reject the transaction. - placeholder: Enter compiled clear-state program as base64 text + placeholder: Enter compiled clear-state program as Base64 text apgs_nui: # Number of global ints label: Number of global integers tip: > @@ -444,3 +445,9 @@ fields: {{count}}. more_info: More information about this field. more_info_section: More information about this section. + base64: + label: Base64 encoded data + with_label: "{{label}} (Base64)" + tip: > + Interpret the data entered into the above field as Base64 encoded data. + error: This must be valid Base64 encoded data. diff --git a/src/app/i18n/locales/es/compose_txn.yml b/src/app/i18n/locales/es/compose_txn.yml index a58b6ae5..3001cae2 100644 --- a/src/app/i18n/locales/es/compose_txn.yml +++ b/src/app/i18n/locales/es/compose_txn.yml @@ -63,6 +63,7 @@ fields: tip: > Datos a adjuntar a esta transacción. Ejemplos: mensaje en texto, datos JSON, datos en bytes placeholder: Introduzca la nota… + placeholder_b64: Introduzca la nota como texto Base64… use_sug_rounds: # Use suggested first & last rounds? label: Establecer automáticamente rondas válidas tip: > @@ -275,15 +276,15 @@ fields: # Fields for key registration type votekey: # Voting key label: Llave de votación - tip: La clave pública de participación raíz codificada en base64. + tip: La clave pública de participación raíz codificada en Base64. placeholder: Llave de votación selkey: # Selection key label: Llave de selección - tip: La clave pública VRF codificada en base64. + tip: La clave pública VRF codificada en Base64. placeholder: Llave de selección sprfkey: # State proof key label: Llave de prueba de estado - tip: El compromiso de clave pública de prueba de estado de 64 bytes codificado en base64. + tip: El compromiso de clave pública de prueba de estado de 64 bytes codificado en Base64. placeholder: Llave de prueba de estado votefst: # First voting round label: Primera ronda de votaciones @@ -333,7 +334,7 @@ fields: la aplicación se establece en «borrar estado». Puede leer y escribir el estado global de la aplicación, así como el estado local específico de la cuenta. Los programas de aprobación pueden rechazar la transacción. - placeholder: Introduzca el programa compilado de aprobación como texto base64 + placeholder: Introduzca el programa compilado de aprobación como texto Base64 apsu: # Clear state program label: Programa para borrar estado tip: > @@ -341,7 +342,7 @@ fields: establecido en «borrar estado». Puede leer y escribir el estado global de la aplicación, así como el estado local específico de la cuenta. Los programas de borrado de estado no pueden rechazar la transacción. - placeholder: Introduzca el programa compilado para borrar estado como texto base64 + placeholder: Introduzca el programa compilado para borrar estado como texto Base64 apgs_nui: # Number of global ints label: Número de enteros globales tip: > @@ -454,3 +455,9 @@ fields: superior a {{count, number}}. more_info: Más información sobre este campo. more_info_section: Más información sobre esta sección. + base64: + label: Datos codificados en Base64 + with_label: "{{label}} (Base64)" + tip: > + Interprete los datos introducidos en el campo anterior como datos codificados en Base64. + error: Debe contener datos válidos codificados en Base64. diff --git a/src/app/lib/txn-data/atoms.ts b/src/app/lib/txn-data/atoms.ts index ce1dffef..beb27aad 100644 --- a/src/app/lib/txn-data/atoms.ts +++ b/src/app/lib/txn-data/atoms.ts @@ -6,12 +6,9 @@ import { splitAtom } from 'jotai/utils'; import { atomWithValidate } from 'jotai-form'; import { ASSET_NAME_MAX_LENGTH, - LEASE_MAX_LENGTH, MAX_APP_EXTRA_PAGES, MAX_DECIMAL_PLACES, - METADATA_HASH_LENGTH, MIN_TX_FEE, - NOTE_MAX_LENGTH, UNIT_NAME_MAX_LENGTH, URL_MAX_LENGTH } from './constants'; @@ -46,13 +43,10 @@ export const fee = atomWithValidate(undefined, { export const useSugFee = atomWithValidate(true, { validate: v => v }); /** Note */ -export const note = atomWithValidate(undefined, { - validate: v => { - // When using UTF-8, the maximum number of characters is around (max bytes / 4) - YupString().max(NOTE_MAX_LENGTH / 4).validateSync(v === '' ? undefined : v); - return v; - } -}); +export const note = atomWithValidate(undefined, { validate: v => v }); + +/** Interpret note value as Base64 data */ +export const b64Note = atomWithValidate(false, { validate: v => v }); /** First round */ export const fv = atomWithValidate(undefined, { @@ -68,12 +62,10 @@ export const lv = atomWithValidate(undefined, { export const useSugRounds = atomWithValidate(true, { validate: v => v }); /** Lease */ -export const lx = atomWithValidate('', { - validate: v => { - YupString().trim().max(LEASE_MAX_LENGTH).validateSync(v === '' ? undefined : v); - return v; - } -}); +export const lx = atomWithValidate('', { validate: v => v }); + +/** Interpret lease value as Base64 data */ +export const b64Lx = atomWithValidate(false, { validate: v => v }); /** Rekey to */ export const rekey = atomWithValidate('', { @@ -253,12 +245,10 @@ export const apar_r = atomWithValidate('', { export const apar_rUseSnd = atomWithValidate(true, { validate: v => v }); /** Asset configuration - Metadata hash */ -export const apar_am = atomWithValidate('', { - validate: v => { - YupString().length(METADATA_HASH_LENGTH).validateSync(v === '' ? undefined : v); - return v; - } -}); +export const apar_am = atomWithValidate('', { validate: v => v }); + +/** Interpret metadata hash value as Base64 data */ +export const b64Apar_am = atomWithValidate(false, { validate: v => v }); /* * Asset Freeze diff --git a/src/app/lib/txn-data/constants.ts b/src/app/lib/txn-data/constants.ts index f1aaf289..f44b3a3e 100644 --- a/src/app/lib/txn-data/constants.ts +++ b/src/app/lib/txn-data/constants.ts @@ -5,9 +5,23 @@ import { ALGORAND_MIN_TX_FEE } from "algosdk"; /** Number of characters in a valid account address */ export const ADDRESS_LENGTH = 58; /** Maximum length of a lease in bytes (or characters if only using ASCII characters) */ -export const LEASE_MAX_LENGTH = 32; +export const LEASE_LENGTH = 32; +/** Maximum length of a lease when encoded in base64 + * + * Equation to find length of base64 string for a given number of bytes: + * `b64_length = (num_bytes + 2) / 3 * 4` + * (From: https://stackoverflow.com/a/60067262) + */ +export const B64_LEASE_LENGTH = 44; // (32 + 2) / 3 * 4 /** Maximum length of a note in bytes (or characters if only using ASCII characters) */ export const NOTE_MAX_LENGTH = 1000; +/** Maximum length of a note when encoded in base64 + * + * Equation to find length of base64 string for a given number of bytes: + * `b64_length = (num_bytes + 2) / 3 * 4` + * (From: https://stackoverflow.com/a/60067262) + */ +export const B64_NOTE_MAX_LENGTH = 1336; // (1000 + 2) / 3 * 4 /** Minimum transaction fee in microAlgos */ export const MIN_TX_FEE = ALGORAND_MIN_TX_FEE; @@ -23,6 +37,13 @@ export const ASSET_NAME_MAX_LENGTH = 32; export const URL_MAX_LENGTH = 96; /** Allowed length of an asset's metadata hash. No more and no less (fewer), unless empty. */ export const METADATA_HASH_LENGTH = 32; +/** Maximum length of a metadata hash when encoded in base64 + * + * Equation to find length of base64 string for a given number of bytes: + * `b64_length = (num_bytes + 2) / 3 * 4` + * (From: https://stackoverflow.com/a/60067262) + */ +export const B64_METADATA_HASH_LENGTH = 44; // (32 + 2) / 3 * 4 /** Maximum number of decimal places */ export const MAX_DECIMAL_PLACES = 19; diff --git a/src/app/lib/txn-data/field-validation.ts b/src/app/lib/txn-data/field-validation.ts index 1454046a..8a64e06e 100644 --- a/src/app/lib/txn-data/field-validation.ts +++ b/src/app/lib/txn-data/field-validation.ts @@ -3,10 +3,21 @@ import { OnApplicationComplete } from 'algosdk'; import { atom } from 'jotai'; import { atomWithFormControls, atomWithValidate, validateAtoms } from 'jotai-form'; -import { baseUnitsToDecimal } from '@/app/lib/utils'; +import { base64RegExp, baseUnitsToDecimal } from '@/app/lib/utils'; import * as txnDataAtoms from './atoms'; import { RetrievedAssetInfo, ValidationMessage } from './types'; -import { Preset, MAX_APP_GLOBALS, MAX_APP_KEY_LENGTH, MAX_APP_LOCALS } from './constants'; +import { + Preset, + MAX_APP_GLOBALS, + MAX_APP_KEY_LENGTH, + MAX_APP_LOCALS, + B64_NOTE_MAX_LENGTH, + NOTE_MAX_LENGTH, + B64_LEASE_LENGTH, + LEASE_LENGTH, + B64_METADATA_HASH_LENGTH, + METADATA_HASH_LENGTH +} from './constants'; import { YupMixed, YupNumber, YupString, addressSchema, idSchema } from './validation-rules'; /** Atom containing flag for triggering the form errors to be shown */ @@ -56,10 +67,12 @@ export const generalFormControlAtom = atomWithFormControls({ fee: txnDataAtoms.fee, useSugFee: txnDataAtoms.useSugFee, note: txnDataAtoms.note, + b64Note: txnDataAtoms.b64Note, useSugRounds: txnDataAtoms.useSugRounds, fv: txnDataAtoms.fv, lv: txnDataAtoms.lv, lx: txnDataAtoms.lx, + b64Lx: txnDataAtoms.b64Lx, rekey: txnDataAtoms.rekey, }); export const feeConditionalRequireAtom = validateAtoms({ @@ -108,6 +121,44 @@ export const fvLvFormControlAtom = validateAtoms({ .validateSync(values.fv); } }); +export const noteConditionalMaxAtom = validateAtoms({ + note: txnDataAtoms.note, + b64Note: txnDataAtoms.b64Note, +}, (values) => { + YupString() + .max(values.b64Note ? B64_NOTE_MAX_LENGTH : NOTE_MAX_LENGTH) + .validateSync(values.note === '' ? undefined : values.note); +}); +export const noteConditionalBase64Atom = validateAtoms({ + note: txnDataAtoms.note, + b64Note: txnDataAtoms.b64Note, +}, (values) => { + if (values.b64Note) { + YupString().matches(base64RegExp, { + excludeEmptyString: true, + message: (): ValidationMessage => ({key: 'fields.base64.error'}) + }).validateSync(values.note); + } +}); +export const lxConditionalLengthAtom = validateAtoms({ + lx: txnDataAtoms.lx, + b64Lx: txnDataAtoms.b64Lx, +}, (values) => { + YupString() + .length(values.b64Lx ? B64_LEASE_LENGTH : LEASE_LENGTH) + .validateSync(values.lx === '' ? undefined : values.lx); +}); +export const lxConditionalBase64Atom = validateAtoms({ + lx: txnDataAtoms.lx, + b64Lx: txnDataAtoms.b64Lx, +}, (values) => { + if (values.b64Lx) { + YupString().matches(base64RegExp, { + excludeEmptyString: true, + message: (): ValidationMessage => ({key: 'fields.base64.error'}) + }).validateSync(values.lx); + } +}); /* * Payment validation form groups @@ -184,6 +235,7 @@ export const assetConfigFormControlAtom = atomWithFormControls({ apar_r: txnDataAtoms.apar_r, apar_rUseSnd: txnDataAtoms.apar_rUseSnd, apar_am: txnDataAtoms.apar_am, + b64Apar_am: txnDataAtoms.b64Apar_am, }); export const caidConditionalRequireAtom = validateAtoms({ preset: presetAtom, @@ -211,6 +263,25 @@ export const aparDcConditionalRequireAtom = validateAtoms({ YupNumber().required().validateSync(values.apar_dc); } }); +export const aparAmConditionalLengthAtom = validateAtoms({ + apar_am: txnDataAtoms.apar_am, + b64Apar_am: txnDataAtoms.b64Apar_am, +}, (values) => { + YupString() + .length(values.b64Apar_am ? B64_METADATA_HASH_LENGTH : METADATA_HASH_LENGTH) + .validateSync(values.apar_am === '' ? undefined : values.apar_am); +}); +export const aparAmConditionalBase64Atom = validateAtoms({ + apar_am: txnDataAtoms.apar_am, + b64Apar_am: txnDataAtoms.b64Apar_am, +}, (values) => { + if (values.b64Apar_am) { + YupString().matches(base64RegExp, { + excludeEmptyString: true, + message: (): ValidationMessage => ({key: 'fields.base64.error'}) + }).validateSync(values.apar_am); + } +}); /* * Asset freeze validation form group diff --git a/src/app/lib/txn-data/form-validation.ts b/src/app/lib/txn-data/form-validation.ts index bc57317a..870697f0 100644 --- a/src/app/lib/txn-data/form-validation.ts +++ b/src/app/lib/txn-data/form-validation.ts @@ -26,6 +26,12 @@ export function isFormValid( const fee = jotaiStore.get(FieldValidation.feeConditionalRequireAtom); if (!fee.isValidating && !fee.isValid) invalidGeneralFields.add('fee'); + // If "note" field did not meet the conditional validation + const noteMax = jotaiStore.get(FieldValidation.noteConditionalMaxAtom); + if (!noteMax.isValidating && !noteMax.isValid) invalidGeneralFields.add('note'); + const noteB64 = jotaiStore.get(FieldValidation.noteConditionalBase64Atom); + if (!noteB64.isValidating && !noteB64.isValid) invalidGeneralFields.add('note'); + // If "first valid round" field did not meet the conditional validation const fv = jotaiStore.get(FieldValidation.fvConditionalRequireAtom); if (!fv.isValidating && !fv.isValid) invalidGeneralFields.add('fv'); @@ -39,6 +45,12 @@ export function isFormValid( const fvLvComparison = jotaiStore.get(FieldValidation.fvLvFormControlAtom); if (!fvLvComparison.isValidating && !fvLvComparison.isValid) invalidGeneralFields.add('fv'); + // If "lease" field did not meet the conditional validation + const lxLength = jotaiStore.get(FieldValidation.lxConditionalLengthAtom); + if (!lxLength.isValidating && !lxLength.isValid) invalidGeneralFields.add('lx'); + const lxB64 = jotaiStore.get(FieldValidation.lxConditionalBase64Atom); + if (!lxB64.isValidating && !lxB64.isValid) invalidGeneralFields.add('lx'); + // If "rekey address" field did not meet the conditional validation const rekey = jotaiStore.get(FieldValidation.rekeyConditionalRequireAtom); if (!rekey.isValidating && !rekey.isValid) invalidGeneralFields.add('rekey'); @@ -102,18 +114,26 @@ export function isFormValid( const assetConfigForm = jotaiStore.get(FieldValidation.assetConfigFormControlAtom); const invalidAssetConfigFields = getInvalidFields(assetConfigForm); - // If "asset ID" field did not meet the condtional validation + // If "asset ID" field did not meet the conditional validation const caid = jotaiStore.get(FieldValidation.caidConditionalRequireAtom); if (!caid.isValidating && !caid.isValid) invalidAssetConfigFields.add('caid'); - // If "asset total" field did not meet the condtional validation + // If "asset total" field did not meet the conditional validation const aparT = jotaiStore.get(FieldValidation.aparTConditionalRequireAtom); if (!aparT.isValidating && !aparT.isValid) invalidAssetConfigFields.add('apar_t'); - // If "asset decimals" field did not meet the condtional validation + // If "asset decimals" field did not meet the conditional validation const aparDc = jotaiStore.get(FieldValidation.aparDcConditionalRequireAtom); if (!aparDc.isValidating && !aparDc.isValid) invalidAssetConfigFields.add('apar_dc'); + // If "metadata hash" field did not meet the conditional validation + const aparAmLength = jotaiStore.get(FieldValidation.aparAmConditionalLengthAtom); + if (!aparAmLength.isValidating && !aparAmLength.isValid) { + invalidAssetConfigFields.add('apar_am'); + }; + const aparAmB64 = jotaiStore.get(FieldValidation.aparAmConditionalBase64Atom); + if (!aparAmB64.isValidating && !aparAmB64.isValid) invalidAssetConfigFields.add('apar_am'); + if (!invalidGeneralFields.size) scrollToFirstInvalidField(invalidAssetConfigFields); areOtherFieldsInvalid = !!invalidAssetConfigFields.size; diff --git a/src/app/lib/txn-data/processor.test.ts b/src/app/lib/txn-data/processor.test.ts index 3ff1b2a8..5102d94a 100644 --- a/src/app/lib/txn-data/processor.test.ts +++ b/src/app/lib/txn-data/processor.test.ts @@ -51,6 +51,44 @@ describe('Transaction Data Processor', () => { .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a payment transaction with byte array properties', () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.pay, + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + rcv: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + amt: 5, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + rekey: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + close: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.pay); + expect(addrToStr(txn.from)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(addrToStr(txn.reKeyTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(addrToStr(txn.to)).toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(txn.amount).toBe(5000000); + expect(addrToStr(txn.closeRemainderTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + }); + it('returns `Transaction` object with given data for a asset transfer transaction', () => { const txn = processor.createTxnFromData( { @@ -94,8 +132,52 @@ describe('Transaction Data Processor', () => { .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); }); - it('returns `Transaction` object with given data for a asset configuration (creation)' - + ' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset transfer transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.axfer, + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + rekey: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + asnd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + arcv: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + xaid: 88888888, + aamt: 500, + aclose: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.axfer); + expect(addrToStr(txn.from)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(addrToStr(txn.reKeyTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(addrToStr(txn.assetRevocationTarget)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.to)).toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(txn.assetIndex).toBe(88888888); + expect(txn.amount.toString()).toBe('500'); + expect(addrToStr(txn.closeRemainderTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset configuration (creation) transaction', () => { const txn = processor.createTxnFromData( { @@ -153,8 +235,66 @@ describe('Transaction Data Processor', () => { expect(txn.assetMetadataHash).toHaveLength(32); }); - it('returns `Transaction` object with given data for a asset configuration (destroy)' - + ' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset configuration (creation) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.acfg, + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + rekey: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + apar_un: 'FAKE', + apar_an: 'Fake Token', + apar_t: 10000000, + apar_dc: 5, + apar_df: true, + apar_au: 'https://fake.token', + apar_m: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_f: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_c: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_r: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_am: textEncoder.encode('BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), // byte array + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.acfg); + expect(addrToStr(txn.from)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(addrToStr(txn.reKeyTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(txn.assetUnitName).toBe('FAKE'); + expect(txn.assetName).toBe('Fake Token'); + expect(txn.assetTotal.toString()).toBe('10000000'); + expect(txn.assetDecimals).toBe(5); + expect(txn.assetDefaultFrozen).toBe(true); + expect(txn.assetURL).toBe('https://fake.token'); + expect(addrToStr(txn.assetManager)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.assetFreeze)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.assetClawback)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.assetReserve)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(textDecoder.decode(txn.assetMetadataHash)).toBe('BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset configuration (destroy) transaction', () => { const txn = processor.createTxnFromData( { @@ -189,8 +329,43 @@ describe('Transaction Data Processor', () => { expect(txn.assetIndex).toBe(88888888); }); - it('returns `Transaction` object with given data for a asset configuration (reconfiguration)' - + ' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset configuration (destroy) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.acfg, + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + rekey: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + caid: 88888888, + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.acfg); + expect(addrToStr(txn.from)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(addrToStr(txn.reKeyTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + + expect(txn.assetIndex).toBe(88888888); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset configuration (reconfiguration) transaction', () => { const txn = processor.createTxnFromData( { @@ -236,6 +411,52 @@ describe('Transaction Data Processor', () => { .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset configuration (reconfiguration) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.acfg, + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + rekey: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + caid: 88888888, + apar_m: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_f: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_c: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + apar_r: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.acfg); + expect(addrToStr(txn.from)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(addrToStr(txn.reKeyTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(txn.assetIndex).toBe(88888888); + expect(addrToStr(txn.assetManager)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.assetFreeze)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.assetClawback)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + expect(addrToStr(txn.assetReserve)) + .toBe('EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4'); + }); + it('returns `Transaction` object with given data for a asset freeze transaction', () => { const txn = processor.createTxnFromData( { @@ -272,6 +493,43 @@ describe('Transaction Data Processor', () => { expect(txn.freezeState).toBe(true); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a asset freeze transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.afrz, + snd: 'EW64GC6F24M7NDSC5R3ES4YUVE3ZXXNMARJHDCCCLIHZU6TBEOC7XRSBG4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + rekey: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + faid: 88888888, + fadd: 'GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A', + afrz: true, + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.afrz); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(addrToStr(txn.reKeyTo)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(txn.assetIndex).toBe(88888888); + expect(addrToStr(txn.freezeAccount)) + .toBe('GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'); + expect(txn.freezeState).toBe(true); + }); + it('returns `Transaction` object with given data for a key registration (online) transaction', () => { const txn = processor.createTxnFromData( @@ -304,20 +562,144 @@ describe('Transaction Data Processor', () => { expect(txn.fee).toBe(1000); expect(txn.firstRound).toBe(6000000); expect(txn.lastRound).toBe(6001000); - expect(txn.lease).toHaveLength(32); - expect(txn.voteKey.toString('base64')).toBe('G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo='); - expect(txn.selectionKey.toString('base64')) - .toBe('LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk='); - expect(txn.stateProofKey.toString('base64')).toBe( - 'RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==' - ); - expect(txn.voteFirst).toBe(6000000); - expect(txn.voteLast).toBe(6100000); - expect(txn.voteKeyDilution).toBe(1730); + expect(txn.lease).toHaveLength(32); + expect(txn.voteKey.toString('base64')).toBe('G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo='); + expect(txn.selectionKey.toString('base64')) + .toBe('LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk='); + expect(txn.stateProofKey.toString('base64')).toBe( + 'RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==' + ); + expect(txn.voteFirst).toBe(6000000); + expect(txn.voteLast).toBe(6100000); + expect(txn.voteKeyDilution).toBe(1730); + expect(txn.nonParticipation).toBe(false); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a key registration (online) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.keyreg, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + votekey: 'G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo=', + selkey: 'LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk=', + // eslint-disable-next-line max-len + sprfkey: 'RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==', + votefst: 6000000, + votelst: 6100000, + votekd: 1730, + nonpart: false, + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.keyreg); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.voteKey.toString('base64')).toBe('G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo='); + expect(txn.selectionKey.toString('base64')) + .toBe('LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk='); + expect(txn.stateProofKey.toString('base64')).toBe( + 'RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==' + ); + expect(txn.voteFirst).toBe(6000000); + expect(txn.voteLast).toBe(6100000); + expect(txn.voteKeyDilution).toBe(1730); + expect(txn.nonParticipation).toBe(false); + }); + + it('returns `Transaction` object with given data for a key registration (offline) transaction', + () => { + const txn = processor.createTxnFromData( + { + type: TransactionType.keyreg, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: 'Hello world', + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + votekey: '', + selkey: '', + // eslint-disable-next-line max-len + sprfkey: '', + nonpart: false, + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.keyreg); + + const noteText = (new TextDecoder).decode(txn.note); + expect(noteText).toBe('Hello world'); + + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(txn.lease).toHaveLength(32); + expect(txn.voteKey).toBeUndefined(); + expect(txn.selectionKey).toBeUndefined(); + expect(txn.stateProofKey).toBeUndefined(); + expect(txn.voteFirst).toBeUndefined(); + expect(txn.voteLast).toBeUndefined(); + expect(txn.voteKeyDilution).toBeUndefined(); + expect(txn.nonParticipation).toBe(false); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a key registration (offline) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.keyreg, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + votekey: '', + selkey: '', + // eslint-disable-next-line max-len + sprfkey: '', + nonpart: false, + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.keyreg); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.voteKey).toBeUndefined(); + expect(txn.selectionKey).toBeUndefined(); + expect(txn.stateProofKey).toBeUndefined(); + expect(txn.voteFirst).toBeUndefined(); + expect(txn.voteLast).toBeUndefined(); + expect(txn.voteKeyDilution).toBeUndefined(); expect(txn.nonParticipation).toBe(false); }); - it('returns `Transaction` object with given data for a key registration (offline) transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a key registration (nonparticipating) transaction', () => { const txn = processor.createTxnFromData( { @@ -332,7 +714,7 @@ describe('Transaction Data Processor', () => { selkey: '', // eslint-disable-next-line max-len sprfkey: '', - nonpart: false, + nonpart: true, }, 'testnet-v1.0', 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', @@ -353,21 +735,23 @@ describe('Transaction Data Processor', () => { expect(txn.voteFirst).toBeUndefined(); expect(txn.voteLast).toBeUndefined(); expect(txn.voteKeyDilution).toBeUndefined(); - expect(txn.nonParticipation).toBe(false); + expect(txn.nonParticipation).toBe(true); }); - it('returns `Transaction` object with given data for a key registration (nonparticipating)' - +' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a key registration (nonparticipating) transaction with byte array properties', () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; const txn = processor.createTxnFromData( { type: TransactionType.keyreg, snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', - note: 'Hello world', + note: textEncoder.encode('Hello world'), // byte array fee: 0.001, fv: 6000000, lv: 6001000, - lx: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array votekey: '', selkey: '', // eslint-disable-next-line max-len @@ -379,14 +763,11 @@ describe('Transaction Data Processor', () => { ); expect(txn.type).toBe(TransactionType.keyreg); - - const noteText = (new TextDecoder).decode(txn.note); - expect(noteText).toBe('Hello world'); - + expect(textDecoder.decode(txn.note)).toBe('Hello world'); expect(txn.fee).toBe(1000); expect(txn.firstRound).toBe(6000000); expect(txn.lastRound).toBe(6001000); - expect(txn.lease).toHaveLength(32); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); expect(txn.voteKey).toBeUndefined(); expect(txn.selectionKey).toBeUndefined(); expect(txn.stateProofKey).toBeUndefined(); @@ -447,6 +828,57 @@ describe('Transaction Data Processor', () => { expect(txn.boxes).toHaveLength(2); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (create) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apap: 'BYEB', + apsu: 'BYEB', + apgs_nui: 1, + apgs_nbs: 2, + apls_nui: 3, + apls_nbs: 4, + apep: 1, + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appApprovalProgram.toString()).toBe('66,89,69,66'); + expect(txn.appClearProgram.toString()).toBe('66,89,69,66'); + expect(txn.appGlobalInts).toBe(1); + expect(txn.appGlobalByteSlices).toBe(2); + expect(txn.appLocalInts).toBe(3); + expect(txn.appLocalByteSlices).toBe(4); + expect(txn.extraPages).toBe(1); + expect(txn.appArgs).toHaveLength(3); + expect(txn.appAccounts).toHaveLength(1); + expect(txn.appForeignApps).toHaveLength(2); + expect(txn.appForeignAssets).toHaveLength(3); + expect(txn.boxes).toHaveLength(2); + }); + it('returns `Transaction` object with given data for a application call (update) transaction', () => { const txn = processor.createTxnFromData( @@ -492,6 +924,51 @@ describe('Transaction Data Processor', () => { expect(txn.boxes).toHaveLength(2); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (update) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apan: OnApplicationComplete.UpdateApplicationOC, + apid: 88888888, + apap: 'BYEB', + apsu: 'BYEB', + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appOnComplete).toBe(OnApplicationComplete.UpdateApplicationOC); + expect(txn.appIndex).toBe(88888888); + expect(txn.appApprovalProgram.toString()).toBe('66,89,69,66'); + expect(txn.appClearProgram.toString()).toBe('66,89,69,66'); + expect(txn.appArgs).toHaveLength(3); + expect(txn.appAccounts).toHaveLength(1); + expect(txn.appForeignApps).toHaveLength(2); + expect(txn.appForeignAssets).toHaveLength(3); + expect(txn.boxes).toHaveLength(2); + }); + it('returns `Transaction` object with given data for a application call (delete) transaction', () => { const txn = processor.createTxnFromData( @@ -528,6 +1005,42 @@ describe('Transaction Data Processor', () => { expect(txn.appIndex).toBe(88888888); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (delete) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apan: OnApplicationComplete.DeleteApplicationOC, + apid: 88888888, + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appOnComplete).toBe(OnApplicationComplete.DeleteApplicationOC); + expect(txn.appIndex).toBe(88888888); + }); + it('returns `Transaction` object with given data for a application call (opt-in) transaction', () => { const txn = processor.createTxnFromData( @@ -564,8 +1077,44 @@ describe('Transaction Data Processor', () => { expect(txn.appIndex).toBe(88888888); }); - it('returns `Transaction` object with given data for a application call (close out)' - + ' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (opt-in) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apan: OnApplicationComplete.OptInOC, + apid: 88888888, + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appOnComplete).toBe(OnApplicationComplete.OptInOC); + expect(txn.appIndex).toBe(88888888); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (close out) transaction', () => { const txn = processor.createTxnFromData( { @@ -601,8 +1150,44 @@ describe('Transaction Data Processor', () => { expect(txn.appIndex).toBe(88888888); }); - it('returns `Transaction` object with given data for a application call (clear state)' - +' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (close out) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apan: OnApplicationComplete.CloseOutOC, + apid: 88888888, + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appOnComplete).toBe(OnApplicationComplete.CloseOutOC); + expect(txn.appIndex).toBe(88888888); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (clear state) transaction', () => { const txn = processor.createTxnFromData( { @@ -643,8 +1228,49 @@ describe('Transaction Data Processor', () => { expect(txn.boxes).toHaveLength(2); }); - it('returns `Transaction` object with given data for a application call (no-op call)' - + ' transaction', + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (clear state) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apan: OnApplicationComplete.ClearStateOC, + apid: 88888888, + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appOnComplete).toBe(OnApplicationComplete.ClearStateOC); + expect(txn.appIndex).toBe(88888888); + expect(txn.appArgs).toHaveLength(3); + expect(txn.appAccounts).toHaveLength(1); + expect(txn.appForeignApps).toHaveLength(2); + expect(txn.appForeignAssets).toHaveLength(3); + expect(txn.boxes).toHaveLength(2); + }); + + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (no-op call) transaction', () => { const txn = processor.createTxnFromData( { @@ -680,6 +1306,42 @@ describe('Transaction Data Processor', () => { expect(txn.appIndex).toBe(88888888); }); + // eslint-disable-next-line max-len + it('returns `Transaction` object with given data for a application call (no-op call) transaction with byte array properties', + () => { + const textEncoder = new TextEncoder; + const textDecoder = new TextDecoder; + const txn = processor.createTxnFromData( + { + type: TransactionType.appl, + snd: 'MWAPNXBDFFD2V5KWXAHWKBO7FO4JN36VR4CIBDKDDE7WAUAGZIXM3QPJW4', + note: textEncoder.encode('Hello world'), // byte array + fee: 0.001, + fv: 6000000, + lv: 6001000, + lx: textEncoder.encode('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), // byte array + apan: OnApplicationComplete.NoOpOC, + apid: 88888888, + apaa: ['foo', '42', ''], + apat: ['GD64YIY3TWGDMCNPP553DZPPR6LDUSFQOIJVFDPPXWEG3FVOJCCDBBHU5A'], + apfa: [11111111, 22222222], + apas: [33333333, 44444444, 55555555], + apbx: [{i: 2, n: 'Box 1' }, {i: 99999999, n: 'Boxy box' }], + }, + 'testnet-v1.0', + 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', + ); + + expect(txn.type).toBe(TransactionType.appl); + expect(textDecoder.decode(txn.note)).toBe('Hello world'); + expect(txn.fee).toBe(1000); + expect(txn.firstRound).toBe(6000000); + expect(txn.lastRound).toBe(6001000); + expect(textDecoder.decode(txn.lease)).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(txn.appOnComplete).toBe(OnApplicationComplete.NoOpOC); + expect(txn.appIndex).toBe(88888888); + }); + }); }); diff --git a/src/app/lib/txn-data/processor.ts b/src/app/lib/txn-data/processor.ts index a7aa3f3e..6bdcc5d9 100644 --- a/src/app/lib/txn-data/processor.ts +++ b/src/app/lib/txn-data/processor.ts @@ -20,7 +20,8 @@ export function createTxnFromData( case algosdk.TransactionType.pay: return createPayTxn(txnData as TxnData.PaymentTxnData, genesisID, genesisHash, flatFee); case algosdk.TransactionType.axfer: - return createAxferTxn(txnData as TxnData.AssetTransferTxnData, + return createAxferTxn( + txnData as TxnData.AssetTransferTxnData, genesisID, genesisHash, flatFee @@ -45,15 +46,17 @@ function createPayTxn( genesisHash: string, flatFee = true ) { + const fee = algosdk.algosToMicroalgos(payTxnData.fee); const txn = algosdk.makePaymentTxnWithSuggestedParamsFromObject({ from: payTxnData.snd, to: payTxnData.rcv, amount: algosdk.algosToMicroalgos(payTxnData.amt), - note: encodeTransactionNote(payTxnData.note), + note: payTxnData.note?.constructor === Uint8Array + ? payTxnData.note : encodeTransactionNote(payTxnData.note), rekeyTo: payTxnData.rekey || undefined, closeRemainderTo: payTxnData.close || undefined, suggestedParams: { - fee: algosdk.algosToMicroalgos(payTxnData.fee), + fee, flatFee, firstRound: payTxnData.fv, lastRound: payTxnData.lv, @@ -63,7 +66,11 @@ function createPayTxn( }); if (payTxnData.lx) { - txn.addLease((new TextEncoder).encode(payTxnData.lx)); + txn.addLease( + payTxnData.lx.constructor === Uint8Array + ? payTxnData.lx : (new TextEncoder).encode(payTxnData.lx as string), + flatFee ? 0 : fee + ); } return txn; @@ -76,6 +83,7 @@ function createAxferTxn( genesisHash: string, flatFee = true ) { + const fee = algosdk.algosToMicroalgos(axferTxnData.fee); const txn = algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ from: axferTxnData.snd, to: axferTxnData.arcv, @@ -83,10 +91,11 @@ function createAxferTxn( amount: BigInt(axferTxnData.aamt), closeRemainderTo: axferTxnData.aclose || undefined, revocationTarget: axferTxnData.asnd || undefined, - note: encodeTransactionNote(axferTxnData.note), + note: axferTxnData.note?.constructor === Uint8Array + ? axferTxnData.note : encodeTransactionNote(axferTxnData.note), rekeyTo: axferTxnData.rekey || undefined, suggestedParams: { - fee: algosdk.algosToMicroalgos(axferTxnData.fee), + fee, flatFee, firstRound: axferTxnData.fv, lastRound: axferTxnData.lv, @@ -96,7 +105,11 @@ function createAxferTxn( }); if (axferTxnData.lx) { - txn.addLease((new TextEncoder).encode(axferTxnData.lx)); + txn.addLease( + axferTxnData.lx.constructor === Uint8Array + ? axferTxnData.lx : (new TextEncoder).encode(axferTxnData.lx as string), + flatFee ? 0 : fee + ); } return txn; @@ -109,12 +122,14 @@ function createAcfgTxn( genesisHash: string, flatFee = true ) { + const fee = algosdk.algosToMicroalgos(acfgTxnData.fee); let txn; if (!acfgTxnData.caid) { // If asset creation transaction txn = algosdk.makeAssetCreateTxnWithSuggestedParamsFromObject({ from: acfgTxnData.snd, - note: encodeTransactionNote(acfgTxnData.note), + note: acfgTxnData.note?.constructor === Uint8Array + ? acfgTxnData.note : encodeTransactionNote(acfgTxnData.note), rekeyTo: acfgTxnData.rekey || undefined, unitName: acfgTxnData.apar_un || undefined, assetName: acfgTxnData.apar_an || undefined, @@ -128,7 +143,7 @@ function createAcfgTxn( clawback: acfgTxnData.apar_c || undefined, reserve: acfgTxnData.apar_r || undefined, suggestedParams: { - fee: algosdk.algosToMicroalgos(acfgTxnData.fee), + fee, flatFee, firstRound: acfgTxnData.fv, lastRound: acfgTxnData.lv, @@ -149,7 +164,7 @@ function createAcfgTxn( clawback: acfgTxnData.apar_c || undefined, reserve: acfgTxnData.apar_r || undefined, suggestedParams: { - fee: algosdk.algosToMicroalgos(acfgTxnData.fee), + fee, flatFee, firstRound: acfgTxnData.fv, lastRound: acfgTxnData.lv, @@ -165,7 +180,7 @@ function createAcfgTxn( rekeyTo: acfgTxnData.rekey || undefined, assetIndex: acfgTxnData.caid, suggestedParams: { - fee: algosdk.algosToMicroalgos(acfgTxnData.fee), + fee, flatFee, firstRound: acfgTxnData.fv, lastRound: acfgTxnData.lv, @@ -176,7 +191,11 @@ function createAcfgTxn( } if (acfgTxnData.lx) { - txn.addLease((new TextEncoder).encode(acfgTxnData.lx)); + txn.addLease( + acfgTxnData.lx.constructor === Uint8Array + ? acfgTxnData.lx : (new TextEncoder).encode(acfgTxnData.lx as string), + flatFee ? 0 : fee + ); } return txn; @@ -189,15 +208,17 @@ function createAfrzTxn( genesisHash: string, flatFee = true ) { + const fee = algosdk.algosToMicroalgos(afrzTxnData.fee); const txn = algosdk.makeAssetFreezeTxnWithSuggestedParamsFromObject({ from: afrzTxnData.snd, - note: encodeTransactionNote(afrzTxnData.note), + note: afrzTxnData.note?.constructor === Uint8Array + ? afrzTxnData.note : encodeTransactionNote(afrzTxnData.note), rekeyTo: afrzTxnData.rekey || undefined, assetIndex: afrzTxnData.faid, freezeTarget: afrzTxnData.fadd, freezeState: afrzTxnData.afrz, suggestedParams: { - fee: algosdk.algosToMicroalgos(afrzTxnData.fee), + fee, flatFee, firstRound: afrzTxnData.fv, lastRound: afrzTxnData.lv, @@ -207,7 +228,11 @@ function createAfrzTxn( }); if (afrzTxnData.lx) { - txn.addLease((new TextEncoder).encode(afrzTxnData.lx)); + txn.addLease( + afrzTxnData.lx.constructor === Uint8Array + ? afrzTxnData.lx : (new TextEncoder).encode(afrzTxnData.lx as string), + flatFee ? 0 : fee + ); } return txn; @@ -220,6 +245,7 @@ function createKeyRegTxn( genesisHash: string, flatFee = true ) { + const fee = algosdk.algosToMicroalgos(keyRegTxnData.fee); const keyRegData = keyRegTxnData.nonpart ? { nonParticipation: true } // Activating "nonparticipation" : { @@ -235,10 +261,11 @@ function createKeyRegTxn( const txn = algosdk.makeKeyRegistrationTxnWithSuggestedParamsFromObject({ ...keyRegData, from: keyRegTxnData.snd, - note: encodeTransactionNote(keyRegTxnData.note), + note: keyRegTxnData.note?.constructor === Uint8Array + ? keyRegTxnData.note : encodeTransactionNote(keyRegTxnData.note), rekeyTo: keyRegTxnData.rekey || undefined, suggestedParams: { - fee: algosdk.algosToMicroalgos(keyRegTxnData.fee), + fee, flatFee, firstRound: keyRegTxnData.fv, lastRound: keyRegTxnData.lv, @@ -248,7 +275,11 @@ function createKeyRegTxn( }); if (keyRegTxnData.lx) { - txn.addLease((new TextEncoder).encode(keyRegTxnData.lx)); + txn.addLease( + keyRegTxnData.lx.constructor === Uint8Array + ? keyRegTxnData.lx : (new TextEncoder).encode(keyRegTxnData.lx as string), + flatFee ? 0 : fee + ); } return txn; @@ -261,6 +292,7 @@ function createApplTxn( genesisHash: string, flatFee = true ) { + const fee = algosdk.algosToMicroalgos(applTxnData.fee); const encoder = new TextEncoder; const encodedAppArgs = getAppArgsForTransaction({ accounts: applTxnData.apat, @@ -273,7 +305,8 @@ function createApplTxn( const txn = algosdk.makeApplicationCallTxnFromObject({ ...encodedAppArgs, from: applTxnData.snd, - note: encodeTransactionNote(applTxnData.note), + note: applTxnData.note?.constructor === Uint8Array + ? applTxnData.note : encodeTransactionNote(applTxnData.note), rekeyTo: applTxnData.rekey || undefined, appIndex: applTxnData.apid ?? 0, onComplete: applTxnData.apan, @@ -285,7 +318,7 @@ function createApplTxn( numLocalByteSlices: applTxnData.apls_nbs, extraPages: applTxnData.apep, suggestedParams: { - fee: algosdk.algosToMicroalgos(applTxnData.fee), + fee, flatFee, firstRound: applTxnData.fv, lastRound: applTxnData.lv, @@ -295,7 +328,11 @@ function createApplTxn( }); if (applTxnData.lx) { - txn.addLease(encoder.encode(applTxnData.lx)); + txn.addLease( + applTxnData.lx.constructor === Uint8Array + ? applTxnData.lx : (new TextEncoder).encode(applTxnData.lx as string), + flatFee ? 0 : fee + ); } return txn; diff --git a/src/app/lib/txn-data/stored.ts b/src/app/lib/txn-data/stored.ts index 4fa2632a..eeda2ba8 100644 --- a/src/app/lib/txn-data/stored.ts +++ b/src/app/lib/txn-data/stored.ts @@ -113,7 +113,8 @@ export function loadStoredTxnData( jotaiStore.set(txnDataAtoms.txnType, txnType); jotaiStore.set(txnDataAtoms.snd, storedTxnData?.txn?.snd || ''); - jotaiStore.set(txnDataAtoms.note, storedTxnData?.txn?.note); + jotaiStore.set(txnDataAtoms.b64Note, storedTxnData?.b64Note ?? false); + jotaiStore.set(txnDataAtoms.note, storedTxnData?.txn?.note as string|undefined); jotaiStore.set(txnDataAtoms.useSugFee, storedTxnData?.useSugFee ?? true); jotaiStore.set(txnDataAtoms.fee, storedTxnData?.txn?.fee); jotaiStore.set(txnDataAtoms.useSugRounds, storedTxnData?.useSugRounds ?? true); @@ -121,7 +122,8 @@ export function loadStoredTxnData( jotaiStore.set(txnDataAtoms.lv, storedTxnData?.txn?.lv); if (!preset || preset === Preset.AppRun) { - jotaiStore.set(txnDataAtoms.lx, storedTxnData?.txn?.lx || ''); + jotaiStore.set(txnDataAtoms.lx, (storedTxnData?.txn?.lx as string|undefined) || ''); + jotaiStore.set(txnDataAtoms.b64Lx, storedTxnData?.b64Lx ?? false); } if (!preset || preset === Preset.RekeyAccount) { @@ -175,12 +177,16 @@ export function loadStoredTxnData( jotaiStore.set(txnDataAtoms.caid, undefined); } - jotaiStore.set(txnDataAtoms.apar_un, (storedTxnData?.txn as AssetConfigTxnData)?.apar_un || ''); + jotaiStore.set(txnDataAtoms.apar_un, + ((storedTxnData?.txn as AssetConfigTxnData)?.apar_un as string|undefined) || ''); jotaiStore.set(txnDataAtoms.apar_an, (storedTxnData?.txn as AssetConfigTxnData)?.apar_an || ''); jotaiStore.set(txnDataAtoms.apar_t, (storedTxnData?.txn as AssetConfigTxnData)?.apar_t || ''); jotaiStore.set(txnDataAtoms.apar_dc, (storedTxnData?.txn as AssetConfigTxnData)?.apar_dc); jotaiStore.set(txnDataAtoms.apar_df, !!((storedTxnData?.txn as AssetConfigTxnData)?.apar_df)); jotaiStore.set(txnDataAtoms.apar_au, (storedTxnData?.txn as AssetConfigTxnData)?.apar_au || ''); + jotaiStore.set(txnDataAtoms.apar_am, + ((storedTxnData?.txn as AssetConfigTxnData)?.apar_am as string|undefined) || ''); + jotaiStore.set(txnDataAtoms.b64Apar_am, storedTxnData?.b64Apar_am ?? false); jotaiStore.set(txnDataAtoms.apar_m, (storedTxnData?.txn as AssetConfigTxnData)?.apar_m || ''); jotaiStore.set(txnDataAtoms.apar_f, (storedTxnData?.txn as AssetConfigTxnData)?.apar_f || ''); jotaiStore.set(txnDataAtoms.apar_c, (storedTxnData?.txn as AssetConfigTxnData)?.apar_c || ''); @@ -345,6 +351,7 @@ export function extractTxnDataFromAtoms( apar_fUseSnd?: boolean, apar_cUseSnd?: boolean, apar_rUseSnd?: boolean, + b64Apar_am?: boolean, } = {}; // Gather payment transaction data @@ -443,6 +450,7 @@ export function extractTxnDataFromAtoms( apar_fUseSnd: !!assetConfigForm.values.apar_fUseSnd, apar_cUseSnd: !!assetConfigForm.values.apar_cUseSnd, apar_rUseSnd: !!assetConfigForm.values.apar_rUseSnd, + b64Apar_am: !!assetConfigForm.values.b64Apar_am, }; } else { // Not creating an asset retrievedAssetInfo = jotaiStore.get(txnDataAtoms.retrievedAssetInfo).value; @@ -541,6 +549,8 @@ export function extractTxnDataFromAtoms( txn: {...baseTxnData, ...specificTxnData}, useSugFee: jotaiStore.get(txnDataAtoms.useSugFee).value, useSugRounds: jotaiStore.get(txnDataAtoms.useSugRounds).value, + b64Note: jotaiStore.get(txnDataAtoms.b64Note).value, + b64Lx: jotaiStore.get(txnDataAtoms.b64Lx).value, retrievedAssetInfo: retrievedAssetInfo, ...acfgOptions, }; diff --git a/src/app/lib/txn-data/types.ts b/src/app/lib/txn-data/types.ts index affd4ac5..0774baa1 100644 --- a/src/app/lib/txn-data/types.ts +++ b/src/app/lib/txn-data/types.ts @@ -41,7 +41,7 @@ export interface BaseTxnData { /** Sender */ snd: string; /** Note */ - note?: string; + note?: string | Uint8Array; /** Fee (in Algos, not microAlgos) */ fee: number; /** First valid round */ @@ -51,7 +51,7 @@ export interface BaseTxnData { /** Rekey to */ rekey?: string; /** Lease */ - lx?: string; + lx?: string | Uint8Array; } /** Data for a payment transaction */ export interface PaymentTxnData extends BaseTxnData { @@ -103,7 +103,7 @@ export interface AssetConfigTxnData extends BaseTxnData { /** Reserve address */ apar_r: string; /** Metadata hash */ - apar_am: string; + apar_am: string | Uint8Array; } /** Data for a asset freeze transaction */ export interface AssetFreezeTxnData extends BaseTxnData { @@ -184,6 +184,10 @@ export interface StoredTxnData { useSugFee: boolean; /** Use suggested first & last valid rounds? */ useSugRounds: boolean; + /** Is the note Base64 encoded data? */ + b64Note: boolean; + /** Is the lease Base64 encoded data? */ + b64Lx: boolean; /** Set manager address to the sender address? */ apar_mUseSnd?: boolean; /** Set freeze address to the sender address? */ @@ -192,6 +196,8 @@ export interface StoredTxnData { apar_cUseSnd?: boolean; /** Set reseve address to the sender address? */ apar_rUseSnd?: boolean; + /** Is the metadata hash Base84 encoded data? */ + b64Apar_am?: boolean; /** Information about the asset that was retrieved when the asset ID was given */ retrievedAssetInfo?: RetrievedAssetInfo; } diff --git a/src/app/lib/utils.ts b/src/app/lib/utils.ts index f8b12136..6263a609 100644 --- a/src/app/lib/utils.ts +++ b/src/app/lib/utils.ts @@ -1,6 +1,13 @@ -/** @file Collection of general-purpose utility functions */ +/** @file Collection of general-purpose utility function and constants */ + +/** Regular expression for detecting a valid Base64 string. + * + * From: https://stackoverflow.com/a/7874175 + */ +export const base64RegExp = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; /** Converts bytes as a Uint8Array buffer to data URL. + * * Adapted from: * https://developer.mozilla.org/en-US/docs/Glossary/Base64#converting_arbitrary_binary_data * @@ -22,6 +29,7 @@ export const bytesToBase64DataUrl = async ( }; /** Converts data URL to bytes as a Uint8Array buffer + * * Adapted from: * https://developer.mozilla.org/en-US/docs/Glossary/Base64#converting_arbitrary_binary_data *