diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f2ab157
--- /dev/null
+++ b/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "archive-fs-neo-org",
+ "version": "0.0.1",
+ "private": true,
+ "dependencies": {
+ "@fortawesome/fontawesome-svg-core": "^6.4.0",
+ "@fortawesome/free-brands-svg-icons": "^6.4.0",
+ "@fortawesome/free-regular-svg-icons": "^6.4.0",
+ "@fortawesome/free-solid-svg-icons": "^6.4.0",
+ "@fortawesome/react-fontawesome": "^0.2.0",
+ "@types/react": "^18.2.41",
+ "@types/react-dom": "^18.2.17",
+ "base-58": "^0.0.1",
+ "bulma": "^0.9.4",
+ "react": "^17.0.2",
+ "react-bulma-components": "^4.1.0",
+ "react-dom": "^17.0.2",
+ "react-router-dom": "^6.10.0"
+ },
+ "scripts": {
+ "start": "REACT_APP_VERSION=$(make version) GENERATE_SOURCEMAP=false react-scripts start",
+ "build": "GENERATE_SOURCEMAP=false BUILD_PATH='./archive.fs.neo.org' react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "devDependencies": {
+ "dotenv": "^16.0.3",
+ "react-scripts": "^5.0.1",
+ "typescript": "^4.9.5"
+ }
+}
diff --git a/public/img/close.svg b/public/img/close.svg
new file mode 100644
index 0000000..73396e3
--- /dev/null
+++ b/public/img/close.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/img/cover.png b/public/img/cover.png
new file mode 100644
index 0000000..9b78170
Binary files /dev/null and b/public/img/cover.png differ
diff --git a/public/img/favicon.ico b/public/img/favicon.ico
new file mode 100644
index 0000000..5a1c553
Binary files /dev/null and b/public/img/favicon.ico differ
diff --git a/public/img/logo.svg b/public/img/logo.svg
new file mode 100644
index 0000000..2081f97
--- /dev/null
+++ b/public/img/logo.svg
@@ -0,0 +1,103 @@
+
+
+
+
+
+ image/svg+xml
+
+ NeoFS
+
+
+
+
+
+
+
+ NeoFS
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/img/socials/github.svg b/public/img/socials/github.svg
new file mode 100644
index 0000000..aa05db9
--- /dev/null
+++ b/public/img/socials/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/img/socials/medium.svg b/public/img/socials/medium.svg
new file mode 100644
index 0000000..08a9433
--- /dev/null
+++ b/public/img/socials/medium.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/img/socials/neo.svg b/public/img/socials/neo.svg
new file mode 100644
index 0000000..75e2d8b
--- /dev/null
+++ b/public/img/socials/neo.svg
@@ -0,0 +1,64 @@
+
+
+
+
+
+ image/svg+xml
+
+ Asset 9
+
+
+
+
+
+
+
+ Asset 9
+
+
+
diff --git a/public/img/socials/neo_spcc.svg b/public/img/socials/neo_spcc.svg
new file mode 100644
index 0000000..d0b04c6
--- /dev/null
+++ b/public/img/socials/neo_spcc.svg
@@ -0,0 +1,108 @@
+
+
+
+
+
+ image/svg+xml
+
+ Asset 9
+
+
+
+
+
+
+
+ Asset 9
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/img/socials/twitter.svg b/public/img/socials/twitter.svg
new file mode 100644
index 0000000..1970575
--- /dev/null
+++ b/public/img/socials/twitter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/img/socials/youtube.svg b/public/img/socials/youtube.svg
new file mode 100644
index 0000000..6c30aa1
--- /dev/null
+++ b/public/img/socials/youtube.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..87c5c44
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+ Archive.NeoFS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/src/About.tsx b/src/About.tsx
new file mode 100644
index 0000000..ac19fe2
--- /dev/null
+++ b/src/About.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import {
+ Content,
+ Container,
+ Section,
+ Heading,
+ Tile,
+ Notification,
+} from 'react-bulma-components';
+
+const About = () => {
+ return (
+
+
+
+
+
+ About Service
+
+ Archive.NeoFS is a web application that allows users to create blockchain archives of any span (from block 0 to the current block or a custom range) directly in the browser. It operates fully client-side, leveraging standard NeoFS REST gateway APIs and in-browser streaming techniques to efficiently fetch and store blocks without requiring additional backend processing.
+ The service supports four networks: mainnet, testnet, NeoFS mainnet, and NeoFS testnet. It interacts with the NeoFS REST gateway to retrieve blockchain data stored in NeoFS objects and assembles them into a structured archive format (.acc), that is compatible with both C# Neo node and NeoGo.
+ Frontend part first determines the latest available block in the selected network using the getblockcount method in the rpc request. Each block is stored as a separate object with a unique Object ID (OID), while index files contain references to batches of 128,000 blocks, mapping block indices to their corresponding OIDs. It then retrieves index files, extracts block references, and downloads the corresponding blocks from NeoFS.
+ The process runs entirely in the browser using the showSaveFilePicker
API for file handling and the WritableStream
API for efficient in-browser streaming. Downloaded blocks are written directly into an archive .acc
file, ensuring minimal memory overhead. However, due to API limitations, this feature is only supported in modern browsers: Chrome 86+ (recommended).
+
+
+
+
+
+
+ );
+}
+
+export default About;
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..ea0fe5d
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,386 @@
+body {
+ margin: 0;
+ padding: 0;
+ color: #111827;
+ background: #fff;
+ font-family: 'Poppins', sans-serif;
+ min-width: 300px;
+ -webkit-tap-highlight-color: rgba(255, 255, 255, 0);
+}
+
+a {
+ text-decoration: none;
+}
+
+#about a {
+ color: #02af92
+}
+
+:focus {
+ outline-color: #02af92;
+}
+
+.input:active,
+.input:focus,
+.is-active.input,
+.is-active.textarea,
+.is-focused.input,
+.is-focused.textarea,
+.select select.is-active,
+.select select.is-focused,
+.select select:active,
+.select select:focus,
+.textarea:active,
+.textarea:focus {
+ border-color: #02af92;
+}
+
+.input[disabled] {
+ border-color: #dbdbdb;
+}
+
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.select select[disabled] {
+ border-color: #dbdbdb !important;
+}
+
+.select:not(.is-multiple):not(.is-loading)::after {
+ border-color: #02af92;
+}
+
+.select_block {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ margin-bottom: 0 !important;
+}
+
+.select_block select {
+ min-width: 300px;
+}
+
+.inputs_block {
+ display: flex;
+ justify-content: center;
+}
+
+.inputs_block input {
+ width: 145px;
+ margin: 0 5px;
+}
+
+progress {
+ border-radius: 6px !important;
+ text-align: center;
+ margin: 10px auto !important;
+ max-width: 90%;
+ height: 10px !important;
+}
+
+progress::-webkit-progress-value {
+ background: #02af92 !important;
+}
+
+.navbar-item,
+.navbar-link {
+ color: #ffffff80 !important;
+ background: transparent !important;
+}
+
+.notification a:not(.button):not(.dropdown-item) {
+ text-decoration: none;
+}
+
+.notification {
+ padding: 1.25rem 1.5rem 1.25rem 1.5rem;
+}
+
+a.navbar-item:hover,
+div.navbar-item:hover {
+ cursor: pointer;
+ color: #fff !important;
+}
+
+.navbar,
+.navbar-menu {
+ background: #29363b;
+}
+
+.navbar-burger {
+ color: #ffffff;
+}
+
+.tooltip {
+ position: absolute;
+ min-width: 70px;
+ background: #29363b;
+ text-align: center;
+ padding: 4px 8px;
+ font-size: 12px;
+ border-radius: 4px;
+ color: #fff;
+ top: -80%;
+}
+
+.tooltip:after {
+ position: absolute;
+ border: solid transparent;
+ content: "";
+ height: 0;
+ width: 0;
+ top: 100%;
+ right: 50%;
+ border-width: 6px;
+ margin: -2px -6px;
+ border-top-color: #29363b;
+}
+
+.socials {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.socials a {
+ line-height: 0;
+ margin: 0 10px;
+}
+
+.social_pipe {
+ border-right: 2px solid rgb(0, 0, 0);
+ padding-right: 10px;
+ display: flex;
+}
+
+.button {
+ outline: none;
+ box-shadow: unset !important;
+}
+
+.button.is-primary,
+.notification.is-primary {
+ color: #fff;
+ background: #02af92;
+ border-color: #02af92;
+}
+
+.notification.is-primary {
+ padding: 0.5rem 1rem;
+ margin-bottom: 0.5rem;
+}
+
+.notification>.delete {
+ right: 1rem;
+ top: 0.65rem;
+}
+
+.file.is-boxed .file-cta {
+ border-style: dashed;
+ border-width: 2px;
+}
+
+.file-cta,
+.file-name {
+ white-space: normal;
+ text-align: center;
+}
+
+.file.is-boxed .file-icon {
+ height: 3.5em;
+}
+
+.label {
+ font-weight: 400;
+}
+
+.button.is-focused,
+.button:focus {
+ border-color: inherit;
+}
+
+.footer .subtitle {
+ line-height: 1.5;
+}
+
+code {
+ padding: 0.2em 0.4em;
+ margin: 0;
+ font-size: 85%;
+ white-space: break-spaces;
+ background-color: #eff1f2;
+ color: inherit;
+ border-radius: 6px;
+}
+
+.content pre {
+ color: inherit;
+ border-radius: 4px;
+}
+
+/* modal */
+.modal {
+ position: fixed;
+ z-index: 102;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.modal_close_panel {
+ position: fixed;
+ z-index: 102;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+}
+
+.modal_content {
+ position: absolute;
+ background: #fff;
+ border-radius: 4px;
+ z-index: 103;
+ padding: 1.25rem 1.5rem 1.25rem 1.5rem;
+ min-width: 300px;
+ max-width: 350px;
+ margin: 10px;
+}
+
+.modal_scroll {
+ overflow-y: auto;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+}
+
+.modal_scroll .modal_content {
+ position: relative;
+}
+
+.modal_close {
+ padding: 5px;
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
+.modal_close img {
+ cursor: pointer;
+}
+
+.modal_loader {
+ display: flex;
+ margin: 5px auto 15px;
+ -webkit-animation: pulse 1.5s infinite linear;
+ animation: pulse 1.5s infinite linear;
+}
+
+@media (prefers-color-scheme: dark) {
+ html {
+ background: #2d333b;
+ }
+
+ body {
+ color: #adbac7;
+ background: #22272d;
+ }
+
+ .navbar-menu,
+ .footer,
+ .modal_content {
+ background: #2d333b;
+ }
+
+ .subtitle,
+ .navbar-item,
+ .navbar-link,
+ .label {
+ color: #adbac7 !important;
+ }
+
+ .notification code,
+ .notification pre {
+ background: #343942;
+ }
+
+ .notification.is-primary .subtitle {
+ color: #fff !important;
+ }
+
+ .navbar,
+ .file-cta:hover {
+ background: #2d333b !important;
+ }
+
+ .notification {
+ background: #22272d;
+ border: 2px solid #343942;
+ }
+
+ .file-cta {
+ background: #22272d;
+ border-color: #343942;
+ color: #adbac7 !important;
+ }
+
+ .input,
+ .select select,
+ .textarea {
+ color: #adbac7;
+ background-color: #22272d;
+ border-color: #343942;
+ outline: none !important;
+ }
+
+ .select:not(.is-multiple):not(.is-loading)::after {
+ border-color: #adbac7;
+ }
+
+ .socials a {
+ filter: invert(1);
+ }
+
+ .social_pipe {
+ border-color: #fff;
+ }
+}
+
+@media (min-width: 1025px) {
+ .navbar-menu {
+ margin-right: 6rem;
+ }
+
+ .navbar-brand {
+ margin-left: 6rem;
+ }
+}
+
+@media (max-width: 500px) {
+ .title {
+ font-size: 20px;
+ }
+
+ .section {
+ padding: 1.5rem 1rem;
+ }
+
+ .notification {
+ padding: 1rem;
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..321d014
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,275 @@
+import React, { useState } from 'react';
+import { Link, Route, Routes } from "react-router-dom";
+import { library } from '@fortawesome/fontawesome-svg-core';
+import {
+ Navbar,
+ Heading,
+ Footer,
+ Progress,
+ Button,
+} from 'react-bulma-components';
+import Home from './Home.tsx';
+import About from './About.tsx';
+import NotFound from './NotFound.tsx';
+import 'bulma/css/bulma.min.css';
+import './App.css';
+
+import {
+ faSpinner,
+ faDownload,
+} from '@fortawesome/free-solid-svg-icons';
+
+library.add(
+ faDownload,
+ faSpinner,
+);
+
+interface NetItem {
+ title: string
+ containerId: string
+ rpc: string
+ maxBlock: number
+}
+
+interface Modal {
+ current: string | null
+ params: any
+ btn: string | null | Function
+}
+
+export const App = () => {
+ const [nets, setNets] = useState([{
+ title: 'Mainnet',
+ containerId: '3RCdP3ZubyKyo8qFeo7EJPryidTZaGCMdUjqFJaaEKBV',
+ rpc: 'https://rpc10.n3.nspcc.ru:10331',
+ maxBlock: 0,
+ }, {
+ title: 'Testnet',
+ containerId: 'A8nGtDemWrm2SjfcGAG6wvrxmXwqc5fwr8ezNDm6FraT',
+ rpc: 'https://rpc.t5.n3.nspcc.ru:20331',
+ maxBlock: 0,
+ }, {
+ title: 'NeoFS Mainnet',
+ containerId: 'BP71MqY7nJhpuHfdQU3infRSjMgVmSFFt9GfG2GGMZJj',
+ rpc: 'https://rpc.morph.fs.neo.org',
+ maxBlock: 0,
+ }, {
+ title: 'NeoFS Testnet',
+ containerId: '98xz5YeanzxRCpH6EfUhECVm2MynGYchDN4naJViHT9M',
+ rpc: 'https://rpc1.morph.t5.fs.neo.org',
+ maxBlock: 0,
+ }]);
+ const [currentDownloadedBlock, setCurrentDownloadedBlock] = useState(0);
+ const [menuActive, setMenuActive] = useState(false);
+ const [isLoading, setLoading] = useState(false);
+ const [modal, setModal] = useState({
+ current: null,
+ params: '',
+ btn: null,
+ });
+
+ const onModal = (current: string | null = null, params: any = null, btn: string | null = null) => {
+ setModal({ current, params, btn });
+ };
+
+ const roundNumber = (num: number): number => {
+ const rounded = num.toFixed(2);
+ return parseFloat(rounded) % 1 === 0 ? parseInt(rounded) : parseFloat(rounded);
+ };
+
+ return (
+ <>
+ {(modal.current === 'success' || modal.current === 'failed') && (
+
+
onModal()}
+ />
+
+
onModal()}
+ >
+
+
+
{modal.current === 'success' ? 'Success' : 'Failed'}
+
{modal.params}
+ {typeof modal.btn === 'function' && (
+
typeof modal.btn === 'function' && modal.btn(currentDownloadedBlock)}
+ style={{ minWidth: 300, margin: '10px auto 0', display: 'flex'}}
+ >
+ Retry
+
+ )}
+ {modal.btn === 'about' && (
+
+ Learn more
+
+ )}
+
+
+ )}
+ {modal.current === 'loading' && (
+
+
{} : () => {
+ onModal();
+ setCurrentDownloadedBlock(0);
+ }}
+ />
+
+
{`Snapshot`}
+
{`${modal.params.spanStart} - ${modal.params.spanEnd} (${nets[modal.params.network].title})`}
+
{currentDownloadedBlock / (modal.params.spanEnd - modal.params.spanStart + 1) === 1 ? 'Success!' : 'Writing'}
+
+
{`${currentDownloadedBlock} / ${modal.params.spanEnd - modal.params.spanStart + 1} (${roundNumber((currentDownloadedBlock / (modal.params.spanEnd - modal.params.spanStart + 1)) * 100)}%)`}
+
+
+ )}
+
+
+
+
+
+ setMenuActive(!menuActive)}
+ />
+
+
+
+ setMenuActive(false)}
+ >
+ Download
+
+ setMenuActive(false)}
+ >
+ About
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/Home.tsx b/src/Home.tsx
new file mode 100644
index 0000000..92e6e19
--- /dev/null
+++ b/src/Home.tsx
@@ -0,0 +1,276 @@
+import React, { useState, useEffect } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import {
+ Content,
+ Container,
+ Form,
+ Section,
+ Heading,
+ Tile,
+ Tag,
+ Notification,
+ Button,
+} from 'react-bulma-components';
+import api from './api.ts';
+
+const base58 = require('base-58');
+
+interface NetItem {
+ title: string
+ containerId: string
+}
+
+interface FormData {
+ spanStart: number | ''
+ spanEnd: number | ''
+ network: number
+}
+
+const Home = ({
+ onModal,
+ nets,
+ setNets,
+ setCurrentDownloadedBlock,
+ isLoading,
+ setLoading,
+}) => {
+ const [formData, setFormData] = useState
({
+ spanStart: 0,
+ spanEnd: '',
+ network: 0,
+ });
+
+ let fileHandle: FileSystemFileHandle | null = null;
+
+ useEffect(() => {
+ if (nets[formData.network].maxBlock === 0) {
+ api('POST', nets[formData.network].rpc, {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "getblockcount",
+ "params": []
+ }).then((res: any) => {
+ const netsTemp = [...nets];
+ netsTemp[formData.network].maxBlock = Math.floor(res.result / 128000) * 128000;
+ setNets(netsTemp);
+
+ setFormData({ ...formData, spanEnd: nets[formData.network].maxBlock });
+ }).catch(() => {
+ onModal('failed', 'Failed to fetch the last available block');
+ });
+ } else {
+ if (formData.spanEnd > nets[formData.network].maxBlock) return setFormData({ ...formData, spanEnd: nets[formData.network].maxBlock });
+ }
+ },[formData.network]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const fetchBlocksInRange = async (retryIndex: number | null = null) => {
+ if (formData.spanStart === '' || formData.spanEnd === '' || formData.spanEnd < 0) return onModal('failed', 'Insert correct data');
+ if (formData.spanStart < 0 || formData.spanEnd < 0 || formData.spanStart > nets[formData.network].maxBlock || formData.spanEnd > nets[formData.network].maxBlock) return onModal('failed', 'Insert correct borders');
+
+
+ if (retryIndex === null) {
+ setLoading(true);
+ }
+ const currentNet: NetItem = nets[formData.network];
+
+ try {
+ if (retryIndex === null) {
+ fileHandle = await window.showSaveFilePicker({
+ suggestedName: `chain.${formData.spanStart}.acc`,
+ types: [{ accept: { 'application/octet-stream': ['.acc'] } }],
+ });
+ }
+ onModal('loading', formData);
+
+ const writableStream = await fileHandle?.createWritable(retryIndex === null ? {} : { keepExistingData: true });
+
+ const blockCount = formData.spanEnd - formData.spanStart + 1;
+ if (retryIndex === null) {
+ await writableStream?.write(new Int32Array([blockCount]).buffer);
+ } else {
+ const offset: any = (await fileHandle?.getFile())?.size;
+ writableStream?.seek(offset)
+ }
+
+ const indexFileStart = Math.floor(formData.spanStart / 128000);
+ const indexFileCount = Math.ceil((formData.spanEnd - formData.spanStart) / 128000) + indexFileStart;
+ for (let indexFile = indexFileStart; indexFile <= indexFileCount; indexFile += 1) {
+
+ const indexData: Uint8Array | string = await fetchIndexFile(currentNet, indexFile);
+ if (typeof indexData === 'string') {
+ await writableStream?.close();
+ onModal('failed', indexData, (retryIndexTemp: number) => fetchBlocksInRange(+formData.spanStart + retryIndexTemp));
+ return
+ }
+
+ const uint8Data = new Uint8Array(indexData);
+ const objectsData: string[] = [];
+ for (let i = 0; i < uint8Data.length; i += 32) {
+ const chunk = uint8Data.slice(i, i + 32);
+ const encoded = base58.encode(chunk);
+ objectsData.push(encoded);
+ }
+
+ const startBlock = retryIndex !== null ? retryIndex : formData.spanStart;
+ for (let i = startBlock - (128000 * indexFile); i <= objectsData.length; i += 1) {
+ if (blockCount <= (indexFile * 128000 + i - formData.spanStart)) {
+ await writableStream?.close();
+ return
+ }
+
+ const objectData: Uint8Array | string = await fetchBlock(currentNet, objectsData[i]);
+ if (typeof objectData === 'string') {
+ await writableStream?.close();
+ onModal('failed', objectData, (currentDownloadedBlockTemp: number) => fetchBlocksInRange(+formData.spanStart + currentDownloadedBlockTemp));
+ return
+ }
+
+ const blockSize = new Uint32Array([objectData.byteLength]);
+ await writableStream?.write(blockSize.buffer);
+ await writableStream?.write(new Uint8Array(objectData));
+ setCurrentDownloadedBlock((indexFile * 128000 + i) - formData.spanStart + 1);
+ }
+ }
+ } catch (error: any) {
+ if (error.message.indexOf('showSaveFilePicker is not a function') !== -1) {
+ onModal('failed', 'Your current browser does not support this site\'s functionality. For the best experience, please use Chrome 86+ (recommended).', 'about');
+ } else {
+ onModal('failed', error.message || 'Error occurred during block fetching.', (retryIndexTemp: number) => fetchBlocksInRange(+formData.spanStart + retryIndexTemp));
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchIndexFile = async (currentNet: NetItem, indexNumber: number): Promise => {
+ try {
+ const searchResponse: any = await api('POST', `/objects/${currentNet.containerId}/search?walletConnect=false&offset=0&limit=1`, {
+ filters: [{
+ "key": "Index",
+ "match": "MatchStringEqual",
+ "value": indexNumber.toString(),
+ }],
+ });
+
+ const objectId = searchResponse.objects[0]?.address.objectId;
+ if (!objectId) {
+ return `Error occurred during index fetching #${indexNumber}`;
+ }
+
+ const indexResponse = await api('GET', `/objects/${currentNet.containerId}/by_id/${objectId}?walletConnect=false`);
+ return indexResponse as Uint8Array;
+ } catch (err: any) {
+ return `Error occurred during index fetching #${indexNumber}: ${err.message}`;
+ }
+ };
+
+ const fetchBlock = async (currentNet: NetItem, objectId: string): Promise => {
+ try {
+ const blockResponse = await api('GET', `/objects/${currentNet.containerId}/by_id/${objectId}?walletConnect=false`);
+ return blockResponse as Uint8Array;
+ } catch (err: any) {
+ return `Error occurred during object fetching ${objectId}: ${err.message}`;
+ }
+ };
+
+ return (
+
+
+
+
+
+ Archive.NeoFS – Offline Synchronization Package
+
+ Easily download an offline package of blocks up to a specific block height.
+ Manual steps:
+
+ Choose start
and end
snapshot option for the data range;
+ Select the desired network
;
+ Click the Download button;
+ Wait for .acc
file to download to your device. 🚀
+
+ For best use, use Chrome 86+.
+
+
+
+
+
+
+
+ Prepare snapshot
+
+
+ {
+ if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) {
+ setFormData({ ...formData, spanStart: e.target.value !== '' && e.target.value >= 0 ? Number(e.target.value) : '' });
+ }
+ }}
+ disabled={isLoading}
+ />
+
+
+ {
+ if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) {
+ setFormData({ ...formData, spanEnd: e.target.value !== '' && e.target.value >= 0 ? Number(e.target.value) : '' });
+ }
+ }}
+ disabled={isLoading}
+ />
+
+
+
+
+ setFormData({ ...formData, network: Number(e.target.value) })}
+ value={formData.network}
+ disabled={isLoading}
+ >
+ Mainnet
+ Testnet
+ NeoFS Mainnet
+ NeoFS Testnet
+
+
+
+
+ {`the latest available block is ${nets[formData.network].maxBlock ? nets[formData.network].maxBlock : '-'}`}
+
+
+ fetchBlocksInRange()}
+ style={{ minWidth: 300 }}
+ disabled={isLoading}
+ >
+ Download .acc file
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Home;
diff --git a/src/NotFound.tsx b/src/NotFound.tsx
new file mode 100644
index 0000000..b1f393d
--- /dev/null
+++ b/src/NotFound.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import {
+ Container,
+ Section,
+ Heading,
+ Tile,
+ Notification,
+ Button,
+} from 'react-bulma-components';
+
+const NotFound = () => {
+ return (
+
+
+
+
+
+ 404 Not Found
+ Page not found
+
+ Home page
+
+
+
+
+
+
+ );
+};
+
+export default NotFound;
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..24577ef
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,50 @@
+const server = 'https://rest.fs.neo.org/v1';
+
+type Methods = "GET" | "POST";
+
+async function serverRequest(method: Methods, url: string, params: object, headers: any) {
+ const json: any = {
+ method,
+ headers,
+ }
+
+ if (json['headers']['Content-Type']) {
+ json['body'] = params;
+ } else if (Object.keys(params).length > 0) {
+ json['body'] = JSON.stringify(params);
+ json['headers']['Content-Type'] = 'application/json';
+ }
+
+ let activeUrl: string = url;
+ if (url.indexOf('https') === -1) {
+ activeUrl = `${server}${url}`;
+ }
+
+ return fetch(activeUrl, json).catch((error: any) => error);
+}
+
+export default function api(method: Methods, url: string, params: object = {}, headers: object = {}) {
+ return new Promise((resolve, reject) => {
+ serverRequest(method, url, params, headers).then(async (response: any) => {
+ if (response && response.status === 204) {
+ resolve({ status: 'success' });
+ } else {
+ let res: any = response;
+ if (response?.status === 200 && url.indexOf('by_id') !== -1) {
+ res = await response.arrayBuffer();
+ resolve(res);
+ } else if (response?.status === 200) {
+ res = await response.json();
+ resolve(res);
+ } else {
+ try {
+ res = await response.json();
+ reject(res);
+ } catch (err) {
+ reject(response);
+ }
+ }
+ }
+ });
+ });
+}
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..6302304
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { App } from './App.tsx';
+import { BrowserRouter } from "react-router-dom";
+
+ReactDOM.render(
+
+
+
+
+ ,
+ document.getElementById('root')
+);