diff --git a/package.json b/package.json
new file mode 100644
index 0000000..216ee40
--- /dev/null
+++ b/package.json
@@ -0,0 +1,41 @@
+{
+ "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",
+ "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..44df980
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 @@
+
+
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 @@
+
+
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 @@
+
+
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..3ad41fb
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+ Archive.NeoFS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..e03dfd5
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,381 @@
+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;
+}
+
+: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;
+}
+
+.select_block select {
+ min-width: 300px;
+}
+
+.inputs_block {
+ display: flex;
+ justify-content: center;
+}
+
+.inputs_block input {
+ width: 145px;
+ margin: 0 5px;
+}
+
+progress {
+ border-radius: 4px !important;
+ text-align: center;
+ margin: 15px auto 10px !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..0b9e0bb
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,241 @@
+import React, { useState } from 'react';
+import { Route, Routes } from "react-router-dom";
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { library } from '@fortawesome/fontawesome-svg-core';
+import {
+ Navbar,
+ Heading,
+ Footer,
+ Button,
+ Progress,
+} from 'react-bulma-components';
+import Home from './Home.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
+}
+
+interface Modal {
+ current: string | null
+ params: any
+}
+
+export const App = () => {
+ const [archive, setArchive] = useState([]);
+ const [nets] = useState([{
+ title: 'Mainnet',
+ containerId: '3RCdP3ZubyKyo8qFeo7EJPryidTZaGCMdUjqFJaaEKBV',
+ }, {
+ title: 'Testnet',
+ containerId: 'A8nGtDemWrm2SjfcGAG6wvrxmXwqc5fwr8ezNDm6FraT',
+ }, {
+ title: 'NeoFS Mainnet',
+ containerId: 'BP71MqY7nJhpuHfdQU3infRSjMgVmSFFt9GfG2GGMZJj',
+ }, {
+ title: 'NeoFS Testnet',
+ containerId: '98xz5YeanzxRCpH6EfUhECVm2MynGYchDN4naJViHT9M',
+ }]);
+ const [progress, setProgress] = useState(0);
+ const [isLoading, setLoading] = useState(false);
+ const [modal, setModal] = useState({
+ current: null,
+ params: '',
+ });
+
+ const onModal = (current: string | null = null, params: any = null) => {
+ setModal({ current, params });
+ };
+
+ const saveAsAccFile = (formData: { network: number; spanStart: number; spanEnd: number }) => {
+ if (!archive.length) return;
+
+ const blockCount = new Int32Array([archive.length]);
+
+ const chunks: ArrayBuffer[] = [blockCount.buffer];
+ archive.forEach((block: Uint8Array) => {
+ const blockSize = new Uint32Array([block.byteLength]);
+ chunks.push(blockSize.buffer);
+ chunks.push(new Uint8Array(block));
+ });
+
+ const mergedData = new Blob(chunks, { type: "application/octet-stream" });
+
+ const url = URL.createObjectURL(mergedData);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `snapshot_${nets[formData.network].title}_${formData.spanStart}-${formData.spanEnd}.acc`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+ <>
+ {(modal.current === 'success' || modal.current === 'failed') && (
+
+
onModal()}
+ />
+
+
onModal()}
+ >
+
+
+
{modal.current === 'success' ? 'Success' : 'Failed'}
+
{modal.params}
+
+
+ )}
+ {modal.current === 'loading' && (
+
+
+
Loading
+
{`Snapshot: ${modal.params.spanStart} - ${modal.params.spanEnd} (${nets[modal.params.network].title})`}
+
+
{`Progress: ${progress}%`}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/Home.tsx b/src/Home.tsx
new file mode 100644
index 0000000..1c477b0
--- /dev/null
+++ b/src/Home.tsx
@@ -0,0 +1,180 @@
+import React, { useState } from 'react';
+import {
+ Content,
+ Container,
+ Form,
+ Section,
+ Heading,
+ Tile,
+ Notification,
+ Button,
+} from 'react-bulma-components';
+import api from './api.ts';
+
+interface NetItem {
+ title: string
+ containerId: string
+}
+
+interface FormData {
+ spanStart: number | ''
+ spanEnd: number | ''
+ network: number
+}
+
+const Home = ({
+ onModal,
+ nets,
+ setArchive,
+ setProgress,
+ isLoading,
+ setLoading,
+}) => {
+ const [formData, setFormData] = useState
({
+ spanStart: '',
+ spanEnd: '',
+ network: 0,
+ });
+
+ const roundNumber = (num: number): number => {
+ const rounded = num.toFixed(2);
+ return parseFloat(rounded) % 1 === 0 ? parseInt(rounded) : parseFloat(rounded);
+ };
+
+ const fetchBlocksInRange = async () => {
+ if (formData.spanStart === '' || formData.spanStart < 0 || formData.spanEnd === '' || formData.spanEnd < 0) return onModal('failed', 'Insert correct data');
+
+ onModal('loading', formData);
+ setLoading(true);
+ const currentNet: NetItem = nets[formData.network];
+ let archiveData: Uint8Array[] = [];
+
+ for (let block = formData.spanStart; block <= formData.spanEnd; block++) {
+ const objectId: Uint8Array | string = await fetchBlock(currentNet, block);
+ if (typeof objectId === 'string') {
+ onModal('failed', objectId);
+ return
+ }
+ archiveData.push(objectId);
+ setProgress(roundNumber(((block - formData.spanStart + 1) / (formData.spanEnd - formData.spanStart + 1)) * 100));
+ }
+
+ setArchive(archiveData);
+ setLoading(false);
+ };
+
+ const fetchBlock = async (currentNet: NetItem, blockNumber: number): Promise => {
+ try {
+ const searchResponse: any = await api('POST', `/objects/${currentNet.containerId}/search?walletConnect=false&offset=0&limit=1`, {
+ filters: [{
+ "key": "Block",
+ "match": "MatchStringEqual",
+ "value": blockNumber.toString(),
+ }],
+ });
+
+ const objectId = searchResponse.objects[0]?.address.objectId;
+ if (!objectId) {
+ return `Error: Object not found for block #${blockNumber}`;
+ }
+
+ const blockResponse = await api('GET', `/objects/${currentNet.containerId}/by_id/${objectId}?walletConnect=false`);
+ return blockResponse as Uint8Array;
+ } catch (err: any) {
+ return `Error loading block #${blockNumber}: ${err.message}`;
+ }
+ };
+
+ return (
+
+
+
+
+
+ Archive.NeoFS – Download Blockchain Data Snapshot
+
+ Easily download an offline package of blockchain data 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 the full blockchain data to prepared;
+ - Download the
.acc
file to your device. 🚀
+
+
+
+
+
+
+
+
+
+
+ {
+ if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) {
+ setFormData({ ...formData, spanStart: e.target.value >= 0 ? e.target.value : '' });
+ }
+ }}
+ disabled={isLoading}
+ />
+
+
+ {
+ if (e.target.value === '' || /^[0-9]*[.]?[0-9]*$/.test(e.target.value)) {
+ setFormData({ ...formData, spanEnd: e.target.value >= 0 ? e.target.value : '' });
+ }
+ }}
+ disabled={isLoading}
+ />
+
+
+
+
+ setFormData({ ...formData, network: Number(e.target.value) })}
+ value={formData.network}
+ disabled={isLoading}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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
+
+
+
+
+
+
+
+
+ );
+};
+
+export default NotFound;
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..f697779
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,48 @@
+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 (server) {
+ activeUrl = `${server}${url}`;
+ }
+
+ return fetch(activeUrl, json).catch((error: any) => {
+ console.log(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 {
+ res = await response.json();
+ reject(res);
+ }
+ }
+ });
+ });
+}
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')
+);