diff --git a/javac.20260920_155256.args b/-- удалить javac.20260920_155256.args similarity index 100% rename from javac.20260920_155256.args rename to -- удалить javac.20260920_155256.args diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java index 4f592ea1..4025ff08 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java @@ -20,7 +20,7 @@ public final class ArweaveBlockPublisherScheduler { ArweaveBlocksConfig cfg; try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); } catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; } - if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; } + if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; } if (!STARTED.compareAndSet(false,true)) return; try { ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg); @@ -29,7 +29,7 @@ public final class ArweaveBlockPublisherScheduler { }); Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } }; executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES); - log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway()); + log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes()); } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); } } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java index 0a314b63..4b979459 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java @@ -8,23 +8,56 @@ import shine.db.entities.BlockEntry; import java.util.ArrayList; import java.util.List; -/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */ +/** Publishes locally-created signed user DataItems through the configured transport. */ public final class ArweaveBlockPublisherService { private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class); private final ArweaveBlocksConfig cfg; private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); - private final ArweaveL1Uploader uploader; + private final ArweaveL1Uploader arweaveUploader; + private final TurboDataItemUploader turboUploader; public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) { this.cfg = cfg; - this.uploader = new ArweaveL1Uploader(cfg); + this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null; + this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null; } public int runCycle() throws Exception { + if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0; List candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); if (candidates.isEmpty()) return 0; + return switch (cfg.publishMode()) { + case TURBO -> publishTurbo(candidates); + case ARWEAVE -> publishDirectArweave(candidates); + case NONE -> 0; + }; + } + private int publishTurbo(List candidates) throws Exception { + int published = 0; + Exception firstFailure = null; + for (BlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + try { + TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id); + blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis()); + published++; + log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber()); + } catch (Exception ex) { + if (firstFailure == null) firstFailure = ex; + log.warn("Turbo publish failed: chain={} block={} bytes={} error={}", + e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage()); + } + } + if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published); + if (published == 0 && firstFailure != null) throw firstFailure; + return published; + } + + private int publishDirectArweave(List candidates) throws Exception { List items = new ArrayList<>(); List ids = new ArrayList<>(); long estimated = 32; @@ -39,7 +72,9 @@ public final class ArweaveBlockPublisherService { e.getBchName(), e.getBlockNumber(), raw.length); continue; } - items.add(raw); ids.add(id); estimated = next; + items.add(raw); + ids.add(id); + estimated = next; } if (items.isEmpty()) return 0; @@ -47,13 +82,12 @@ public final class ArweaveBlockPublisherService { List rootTags = List.of( new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"), new ArweaveL1Uploader.Tag("Bundle-Format", "binary"), - new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"), - // Deliberately different from App=test5590 so discovery returns child DataItems only. - new ArweaveL1Uploader.Tag("App", "test5590-batch") + new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0") ); - ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags); - blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis()); - log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length); + ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags); + blocksDAO.markArweavePublished(ids, System.currentTimeMillis()); + log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)", + items.size(), result.txId(), bundle.length); return items.size(); } } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java index 700bbf87..2c249d29 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java @@ -14,7 +14,9 @@ import shine.db.dao.SolanaUserPdaCurrentDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.SolanaUserPdaCurrentEntry; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -24,8 +26,9 @@ import java.time.Duration; import java.util.*; /** - * Discovers App=test5590 child DataItems, extracts their exact serialized bytes - * from the root ANS-104 bundle and imports them through the normal AddBlock checks. + * Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes. + * The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo + * or inside a direct server-created ANS-104 bundle. */ public final class ArweaveBlockSyncService { private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class); @@ -55,10 +58,11 @@ public final class ArweaveBlockSyncService { long minHeight = Math.max(cfg.syncStartBlockHeight(), stored); String cursor = null; long maxHeight = minHeight; + long lowestRetryHeight = Long.MAX_VALUE; int discovered = 0; do { - JsonNode response = graphQlPage(minHeight, cursor, ArweaveBlocksConfig.TEST_TAG_VALUE + "-batch"); + JsonNode response = graphQlPage(minHeight, cursor); JsonNode txs = response.path("data").path("transactions"); if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors")); JsonNode edges = txs.path("edges"); @@ -68,22 +72,27 @@ public final class ArweaveBlockSyncService { for (JsonNode edge : edges) { nextCursor = edge.path("cursor").asText(null); JsonNode node = edge.path("node"); - String rootTx = node.path("id").asText("").trim(); + String dataItemId = node.path("id").asText("").trim(); long height = node.path("block").path("height").asLong(-1L); - if (rootTx.isBlank() || height < 0) continue; + if (dataItemId.isBlank() || height < 0) continue; maxHeight = Math.max(maxHeight, height); - byte[] bundle = downloadRootBundle(rootTx); - for (Ans104Bundle.Entry entry : Ans104Bundle.read(bundle, Math.max(cfg.publishMaxItems() * 4, 100_000))) { - byte[] id32 = entry.id32(); - if (id32 == null || id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) { - continue; - } - Ans104DataItem parsed = new Ans104DataItem(entry.rawDataItem()); - if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue; - if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature in root bundle " + rootTx); - importDAO.enqueueIfMissing(id32, rootTx, height, entry.rawDataItem(), System.currentTimeMillis()); - discovered++; + byte[] id32; + try { + id32 = B64URL.decode(dataItemId); + if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length); + } catch (Exception e) { + log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage()); + continue; + } + if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue; + + try { + byte[] rawDataItem = downloadSignedDataItem(dataItemId); + if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++; + } catch (Exception e) { + lowestRetryHeight = Math.min(lowestRetryHeight, height); + log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage()); } } boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false); @@ -91,9 +100,12 @@ public final class ArweaveBlockSyncService { if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor"); } while (cursor != null); - // Keep one-height overlap: the next query includes this height and deduplicates IDs. - importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis()); - if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight); + // Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item. + long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight); + importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis()); + if (discovered > 0) { + log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint); + } } private void drainQueue() throws Exception { @@ -106,13 +118,12 @@ public final class ArweaveBlockSyncService { if (pending.isEmpty()) break; for (ArweaveBlockImportDAO.QueueItem q : pending) { Ans104DataItem item; - BchBlockEntry block; try { item = new Ans104DataItem(q.rawDataItem()); if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch"); if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag"); if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature"); - block = new BchBlockEntry(q.rawDataItem()); + new BchBlockEntry(q.rawDataItem()); } catch (Exception e) { importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis()); continue; @@ -154,9 +165,12 @@ public final class ArweaveBlockSyncService { stateDAO.insertIfMissing(s); } - private JsonNode graphQlPage(long minHeight, String cursor, String appTagValue) throws Exception { + private JsonNode graphQlPage(long minHeight, String cursor) throws Exception { String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\""; - String query = "query { transactions(tags:[{name:\"App\",values:[\"" + escapeGraphQl(appTagValue) + "\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }"; + String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\"" + + ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:" + + cfg.syncPageSize() + ", after:" + after + + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }"; String body = MAPPER.writeValueAsString(Map.of("query", query)); HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql")) .timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json") @@ -166,17 +180,73 @@ public final class ArweaveBlockSyncService { return MAPPER.readTree(resp.body()); } - private byte[] downloadRootBundle(String txId) throws Exception { - HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId)) - .timeout(Duration.ofMinutes(5)).GET().build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); - if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId); - byte[] body = resp.body(); - if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId); - if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length); - return body; + /** + * Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed + * DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it. + */ + private byte[] downloadSignedDataItem(String dataItemId) throws Exception { + JsonNode offsets = getOffsets(dataItemId); + String rootTxId = offsets.path("rootTxId").asText("").trim(); + long rootOffset = offsets.path("rootOffset").asLong(-1L); + long size = offsets.path("size").asLong(-1L); + if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) { + throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets); + } + if (size > cfg.syncMaxDataItemBytes()) { + throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size); + } + if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow"); + long endInclusive = rootOffset + size - 1; + + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId)) + .timeout(Duration.ofMinutes(2)) + .header("Range", "bytes=" + rootOffset + "-" + endInclusive) + .header("Accept", "application/octet-stream") + .GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream in = resp.body()) { + if (resp.statusCode() != 206) { + throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode()); + } + byte[] bytes = readExactlyBounded(in, (int) size); + if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length); + Ans104DataItem parsed = new Ans104DataItem(bytes); + byte[] expectedId = B64URL.decode(dataItemId); + if (!Arrays.equals(parsed.id32(), expectedId)) { + throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId); + } + return bytes; + } } + private JsonNode getOffsets(String dataItemId) throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId)) + .timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet"); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body())); + } + return MAPPER.readTree(resp.body()); + } + + private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(expected); + byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))]; + int remaining = expected; + while (remaining > 0) { + int n = in.read(buf, 0, Math.min(buf.length, remaining)); + if (n < 0) break; + out.write(buf, 0, n); + remaining -= n; + } + return out.toByteArray(); + } + + private static String safe(String value) { + String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0, 500); + } private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");} private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");} } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java index 116be97f..f97790c6 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java @@ -1,12 +1,14 @@ package server.archive; +import blockchain.Ans104DataItem; import utils.config.AppConfig; import java.nio.file.Path; +import java.util.Locale; -/** Configuration of the new per-user-block ANS-104 Arweave transport. */ +/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */ public record ArweaveBlocksConfig( - boolean publishEnabled, + PublishMode publishMode, int publishIntervalMinutes, int publishMaxItems, long publishMaxBundleBytes, @@ -15,22 +17,44 @@ public record ArweaveBlocksConfig( int minConfirmations, int confirmPollSeconds, int confirmTimeoutMinutes, + String turboUploadUrl, + String turboPaidByAddress, + Path turboWalletJwkPath, boolean syncEnabled, int syncIntervalMinutes, int syncPageSize, int syncQueueBatchSize, long syncStartBlockHeight, - long syncMaxRootBundleBytes, + long syncMaxDataItemBytes, String syncGateway ) { + public enum PublishMode { + TURBO, + ARWEAVE, + NONE; + + static PublishMode parse(String value) { + String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "turbo" -> TURBO; + case "arweave" -> ARWEAVE; + case "none", "" -> NONE; + default -> throw new IllegalArgumentException( + "arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value); + }; + } + } + public static final String TEST_TAG_NAME = "App"; public static final String TEST_TAG_VALUE = "test5590"; - public static final String CHANNEL_TAG_NAME = "c"; + public static final String CHANNEL_TAG_NAME = "c_test5590"; public static ArweaveBlocksConfig load() { AppConfig c = AppConfig.getInstance(); + long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES); + if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES; return new ArweaveBlocksConfig( - c.getBoolean("arweave.blocks.publish.enabled", false), + PublishMode.parse(c.getParam("arweave.blocks.publish.mode")), positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"), positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"), positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"), @@ -39,23 +63,32 @@ public record ArweaveBlocksConfig( nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"), positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"), positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"), + orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"), + blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")), + optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")), c.getBoolean("arweave.blocks.sync.enabled", false), positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"), clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100), positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"), nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"), - positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"), + positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"), orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com") ); } + public boolean publishEnabled() { return publishMode != PublishMode.NONE; } + public void validatePublisher() { - if (publishEnabled && walletJwkPath == null) { - throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled"); + if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) { + throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave"); + } + if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) { + throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo"); } } private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } + private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); } private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); } private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); } private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java new file mode 100644 index 00000000..7aea253b --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java @@ -0,0 +1,106 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; + +/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */ +public final class TurboDataItemUploader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder(); + + public record UploadResult(String dataItemId, String owner) {} + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + private volatile String resolvedPaidByAddress; + + public TurboDataItemUploader(ArweaveBlocksConfig cfg) { + this.cfg = Objects.requireNonNull(cfg); + } + + public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception { + if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty"); + Ans104DataItem item = new Ans104DataItem(rawDataItem); + if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature"); + if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) { + throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id"); + } + + String expectedId = B64URL.encodeToString(item.id32()); + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl())) + .timeout(Duration.ofMinutes(2)) + .header("Content-Type", "application/octet-stream") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem)); + String paidBy = paidByAddress(); + if (paidBy != null) request.header("x-paid-by", paidBy); + + HttpResponse response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String body = response.body() == null ? "" : response.body().trim(); + if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) { + return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + String hint = response.statusCode() == 402 + ? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)" + : ""; + throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body)); + } + + if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); + JsonNode json; + try { json = MAPPER.readTree(body); } + catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); } + String returnedId = json.path("id").asText("").trim(); + if (!returnedId.isBlank() && !returnedId.equals(expectedId)) { + throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId); + } + return new UploadResult(expectedId, json.path("owner").asText("")); + } + + /** + * x-paid-by contains the payer's public native address, never the private key. + * A configured Arweave JWK is used only to derive that public address. + */ + private String paidByAddress() throws Exception { + if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress; + synchronized (this) { + if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress; + String explicit = cfg.turboPaidByAddress(); + if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim(); + if (cfg.turboWalletJwkPath() == null) { + resolvedPaidByAddress = ""; + return null; + } + JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8)); + String modulus = jwk.path("n").asText("").trim(); + if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n"); + byte[] owner = B64URL_DECODER.decode(modulus); + resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner)); + return resolvedPaidByAddress; + } + } + + private static String safe(String value) { + String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0, 500); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java index ec120078..2350b93b 100644 --- a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java @@ -16,7 +16,7 @@ final class Ans104DataItemTest { byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); List tags = List.of( new Ans104DataItem.Tag("App", "test5590"), - new Ans104DataItem.Tag("c", "books") + new Ans104DataItem.Tag("c_test5590", "books") ); byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data); @@ -28,7 +28,7 @@ final class Ans104DataItemTest { assertArrayEquals(owner, parsed.owner32()); assertArrayEquals(data, parsed.data()); assertTrue(parsed.hasTag("App", "test5590")); - assertEquals("books", parsed.tagValue("c")); + assertEquals("books", parsed.tagValue("c_test5590")); assertTrue(parsed.verifySignature()); assertEquals(32, parsed.id32().length); } @@ -51,11 +51,11 @@ final class Ans104DataItemTest { byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); List tags = List.of( new Ans104DataItem.Tag("App", "test5590"), - new Ans104DataItem.Tag("c", "books") + new Ans104DataItem.Tag("c_test5590", "books") ); byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data); - byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9"); + byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87"); assertArrayEquals(expected, actual); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index b5f838ff..9c19edf7 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -41,6 +41,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_22 = 22; public static final int SCHEMA_VERSION_23 = 23; public static final int SCHEMA_VERSION_24 = 24; + public static final int SCHEMA_VERSION_25 = 25; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -65,6 +66,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; + public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql"; private DatabaseInitializer() {} @@ -230,6 +232,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE); currentVersion = SCHEMA_VERSION_24; } + if (currentVersion < SCHEMA_VERSION_25) { + runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE); + currentVersion = SCHEMA_VERSION_25; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java index 371fbf9d..74b8174e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java @@ -6,12 +6,12 @@ import java.sql.*; import java.util.ArrayList; import java.util.List; -/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */ +/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */ public final class ArweaveBlockImportDAO { public static final String STATUS_PENDING = "PENDING"; public static final String STATUS_REJECTED = "REJECTED"; - public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight, + public record QueueItem(byte[] dataItemId, long blockHeight, byte[] rawDataItem, String status, String lastError, long firstSeenAtMs, long updatedAtMs) {} @@ -51,22 +51,21 @@ public final class ArweaveBlockImportDAO { } /** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */ - public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs) + public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs) throws SQLException { String sql = """ INSERT INTO arweave_block_import_queue( - data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms - ) VALUES(?,?,?,?,?,'',?,?) + data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + ) VALUES(?,?,?,?, '',?,?) ON CONFLICT(data_item_id) DO NOTHING """; try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setBytes(1, dataItemId); - ps.setString(2, rootTxId); - ps.setLong(3, blockHeight); - ps.setBytes(4, rawDataItem); - ps.setString(5, STATUS_PENDING); + ps.setLong(2, blockHeight); + ps.setBytes(3, rawDataItem); + ps.setString(4, STATUS_PENDING); + ps.setLong(5, nowMs); ps.setLong(6, nowMs); - ps.setLong(7, nowMs); return ps.executeUpdate() > 0; } } @@ -82,7 +81,7 @@ public final class ArweaveBlockImportDAO { public List listPending(int limit) throws SQLException { int safeLimit = Math.max(1, Math.min(limit, 100_000)); String sql = """ - SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms FROM arweave_block_import_queue WHERE status=? ORDER BY block_height ASC, first_seen_at_ms ASC @@ -95,7 +94,7 @@ public final class ArweaveBlockImportDAO { try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { out.add(new QueueItem( - rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"), + rs.getBytes("data_item_id"), rs.getLong("block_height"), rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"), rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms"))); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 1b40ffe0..e7783ba6 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -27,9 +27,9 @@ public final class BlocksDAO { login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, to_login,to_bch_name,to_block_number,to_block_hash, block_hash,block_signature,data_item_id, - arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + arweave_publish_pending,arweave_published_at_ms, edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { int i = 1; @@ -48,7 +48,6 @@ public final class BlocksDAO { ps.setBytes(i++, e.getDataItemId()); ps.setBoolean(i++, e.isArweavePublishPending()); if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs()); - setNullableString(ps, i++, e.getArweaveRootTxId()); setNullableInt(ps, i++, e.getEditedByBlockNumber()); setNullableInt(ps, i++, e.getLineCode()); setNullableInt(ps, i++, e.getPrevLineNumber()); @@ -74,7 +73,7 @@ public final class BlocksDAO { String sql = """ SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, - arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + arweave_publish_pending,arweave_published_at_ms, edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number FROM blocks WHERE arweave_publish_pending = TRUE @@ -89,18 +88,18 @@ public final class BlocksDAO { return out; } - public void markArweavePublished(List dataItemIds, String rootTxId, long publishedAtMs) throws SQLException { + public void markArweavePublished(List dataItemIds, long publishedAtMs) throws SQLException { if (dataItemIds == null || dataItemIds.isEmpty()) return; String sql = """ UPDATE blocks - SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=? + SET arweave_publish_pending=FALSE, arweave_published_at_ms=? WHERE data_item_id=? """; try (Connection c = db.getConnection()) { c.setAutoCommit(false); try (PreparedStatement ps = c.prepareStatement(sql)) { for (byte[] id : dataItemIds) { - ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch(); + ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch(); } ps.executeBatch(); c.commit(); } catch (Exception e) { c.rollback(); throw e; } @@ -133,7 +132,7 @@ public final class BlocksDAO { private static String baseSelect(){return """ SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, - arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id, + arweave_publish_pending,arweave_published_at_ms, edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number FROM blocks """;} @@ -144,7 +143,7 @@ public final class BlocksDAO { e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes")); e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash")); e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id")); - e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id")); + e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number")); return e; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java index 12df97cf..965de4f9 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java @@ -40,7 +40,6 @@ public class BlockEntry { private byte[] dataItemId; private boolean arweavePublishPending; private Long arweavePublishedAtMs; - private String arweaveRootTxId; private Integer editedByBlockNumber; @@ -95,8 +94,6 @@ public class BlockEntry { public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; } public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; } public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; } - public String getArweaveRootTxId() { return arweaveRootTxId; } - public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; } public Integer getEditedByBlockNumber() { return editedByBlockNumber; } public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql new file mode 100644 index 00000000..8c0484d9 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql @@ -0,0 +1,11 @@ +-- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems. +ALTER TABLE blocks + DROP COLUMN IF EXISTS arweave_root_tx_id; + +ALTER TABLE arweave_block_import_queue + DROP COLUMN IF EXISTS root_tx_id; + +UPDATE db_schema_version +SET schema_version=25, + updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) +WHERE id=1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 9109d1c4..f13a37a4 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -500,7 +500,6 @@ CREATE TABLE IF NOT EXISTS blocks ( data_item_id BYTEA NOT NULL, arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE, arweave_published_at_ms BIGINT, - arweave_root_tx_id TEXT, edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), @@ -535,7 +534,6 @@ VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING; CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( data_item_id BYTEA PRIMARY KEY, - root_tx_id TEXT NOT NULL, block_height BIGINT NOT NULL, raw_data_item BYTEA NOT NULL, status TEXT NOT NULL, @@ -2081,7 +2079,7 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending WHERE archive_head_tx_id <> ''; INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 18829f3e..77663501 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -409,10 +409,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } - // Channel DataItems are indexed by a signed canonical channel slug tag: c=. + // Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=. try { String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry); - String actualChannelSlug = block.getDataItem().tagValue("c"); + String actualChannelSlug = block.getDataItem().tagValue("c_test5590"); if (expectedChannelSlug != null) { if (!expectedChannelSlug.equals(actualChannelSlug)) { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index dfbcff92..12fe9f9e 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -138,11 +138,14 @@ test.freeAvatar.walletJwkPath= # ============================================================ # Arweave per-user-block transport (ANS-104) # Test namespace: each user DataItem is signed with App=test5590. -# Channel DataItems additionally contain c=. +# Channel DataItems additionally contain c_test5590=. +# publish.mode: turbo | arweave | none # ============================================================ -arweave.blocks.publish.enabled=false +arweave.blocks.publish.mode=none arweave.blocks.publish.intervalMinutes=15 arweave.blocks.publish.maxItems=10000 + +# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle. arweave.blocks.publish.maxBundleBytes=134217728 arweave.blocks.publish.gateway=https://arweave.net arweave.blocks.publish.walletJwkPath= @@ -150,10 +153,18 @@ arweave.blocks.publish.minConfirmations=0 arweave.blocks.publish.confirmPollSeconds=30 arweave.blocks.publish.confirmTimeoutMinutes=180 +# Turbo: uploads each already user-signed DataItem separately, without re-signing it. +# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set, +# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo. +# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer. +arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx +arweave.blocks.publish.turbo.paidByAddress= +arweave.blocks.publish.turbo.walletJwkPath= + arweave.blocks.sync.enabled=false arweave.blocks.sync.intervalMinutes=15 arweave.blocks.sync.gateway=https://turbo-gateway.com arweave.blocks.sync.pageSize=100 arweave.blocks.sync.queueBatchSize=10000 arweave.blocks.sync.startBlockHeight=0 -arweave.blocks.sync.maxRootBundleBytes=268435456 +arweave.blocks.sync.maxDataItemBytes=8388608 diff --git a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java index 7e555a78..9a659908 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java @@ -82,7 +82,7 @@ public final class AddBlockSender { List tags = new ArrayList<>(); tags.add(new Ans104DataItem.Tag("App", "test5590")); String channelSlug = channelSlugFor(body); - if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug)); + if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug)); byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame); byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey); diff --git a/VERSION.properties b/VERSION.properties index 156ef164..f668f660 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.23 -server.version=1.10.9 +client.version=1.12.24 +server.version=1.10.10 diff --git a/docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md index a9b49bb5..28a51ce2 100644 --- a/docs/Blockchain/01_Common_Block_Format.md +++ b/docs/Blockchain/01_Common_Block_Format.md @@ -82,7 +82,7 @@ App = test5590 Если блок относится к конкретному каналу, он дополнительно содержит: ```text -c = +c_test5590 = ``` Slug входит в подпись DataItem и не может быть изменён сервером после подписи. @@ -107,7 +107,7 @@ Slug входит в подпись DataItem и не может быть изм 1. распарсить полный ANS-104 DataItem; 2. проверить `App=test5590`; -3. проверить `c`, если тип блока требует канал; +3. проверить `c_test5590`, если тип блока требует канал; 4. проверить ANS-104 Ed25519 подпись; 5. проверить, что `owner` равен текущему blockchain public key пользователя; 6. распарсить Frame v1 и body; diff --git a/docs/Blockchain/17_ANS104_Arweave_Transport.md b/docs/Blockchain/17_ANS104_Arweave_Transport.md index a71759a7..259b1ea1 100644 --- a/docs/Blockchain/17_ANS104_Arweave_Transport.md +++ b/docs/Blockchain/17_ANS104_Arweave_Transport.md @@ -2,96 +2,155 @@ ## Цель -Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle. +Каждый пользовательский блок SHiNE уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер проверяет и хранит **точно эти signed bytes** и может публиковать их одним из двух транспортов: через Turbo по одному DataItem либо через прямую Arweave L1-транзакцию в составе стандартного большого ANS-104 bundle. -## Child DataItem tags +Способ публикации — локальная политика конкретного сервера. Формат пользовательского блока и импорт от него не зависят. -Обязательно для тестового контура: +## User DataItem tags + +Для тестового контура обязательно: ```text App=test5590 ``` -Дополнительно для блоков конкретного канала: +Для блоков конкретного канала дополнительно: ```text -c= +c_test5590= ``` -Теги входят в ANS-104 подпись пользователя. +Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag. -## Publisher +## Publisher modes -По умолчанию цикл — раз в 15 минут. +Настройка: + +```text +arweave.blocks.publish.mode=turbo | arweave | none +``` + +### `turbo` ```text blocks.arweave_publish_pending=true ↓ -готовые serialized DataItems +готовый signed user DataItem из blocks.block_bytes ↓ -ANS-104 binary bundle +POST в Turbo как application/octet-stream ↓ -обычная Arweave L1 transaction +Turbo bundling / Arweave ``` -Если pending-блоков нет, транзакция не создаётся. +DataItem **не переподписывается** сервером. Его `data_item_id = SHA-256(user signature)` до и после загрузки должен оставаться тем же. -Root transaction содержит стандартные bundle tags: +Для Turbo можно задать публичный payer address напрямую: + +```text +arweave.blocks.publish.turbo.paidByAddress=... +``` + +либо путь к серверному Arweave JWK: + +```text +arweave.blocks.publish.turbo.walletJwkPath=/path/to/server-turbo-wallet.json +``` + +Из JWK локально вычисляется только публичный Arweave address для `x-paid-by`; приватный ключ Turbo upload endpoint не получает. + +Важно: если upload уже требует оплаты, а signed DataItem принадлежит другому signer, Turbo Credits серверного кошелька используются через Credit Share Approval в пользу signer-адреса. Для маленьких DataItem, попадающих под действующий free tier Turbo, payer может не понадобиться. Код не должен рассчитывать на вечное существование free tier: HTTP `402` считается ошибкой оплаты и блок остаётся pending. + +### `arweave` + +Сохраняется прежний fallback: + +```text +pending user DataItems + ↓ +standard ANS-104 binary bundle + ↓ +server Arweave RSA/JWK signature + ↓ +Arweave L1 +``` + +Root transaction содержит только стандартные bundle tags: ```text Bundle-Format=binary Bundle-Version=2.0.0 Content-Type=application/octet-stream -App=test5590-batch ``` -`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles. +Специальный `App=test5590-batch` больше не используется. Важны вложенные user DataItems, у которых уже есть `App=test5590`. -После успешной L1-загрузки сервер ставит child-блокам: +### `none` + +Сервер принимает и хранит блоки локально, но publisher не отправляет их в Arweave/Turbo. Importer при этом может работать независимо. + +## Состояние публикации в БД + +После успешной публикации: - `arweave_publish_pending=false`; -- `arweave_published_at_ms`; -- `arweave_root_tx_id`. +- заполняется `arweave_published_at_ms`. -## Importer +`arweave_root_tx_id` больше не хранится: один и тот же пользовательский DataItem может быть физически упакован разными bundler-ами, а стабильным сетевым идентификатором SHiNE является именно `data_item_id`. -Каждый сервер может независимо искать: +## Importer: только individual DataItems + +Importer всегда выполняет один discovery-запрос: ```text App=test5590 ``` -через GraphQL gateway с cursor pagination. +Он **не ищет root bundles** и не зависит от `publish.mode`. -Для каждого нового DataItem: +Это одинаково работает для: -1. взять `id` и `bundledIn.id`; -2. получить root bundle; -3. извлечь точные serialized bytes child DataItem по bundle index; -4. проверить `dataItemId == SHA256(signature)`; -5. проверить ANS-104 Ed25519 подпись; -6. определить пользователя по `owner`; -7. применить обычные проверки `AddBlock`; -8. записать в PostgreSQL с `arweave_publish_pending=false`. +- DataItem, отправленного через Turbo; +- DataItem, находящегося внутри большого direct-Arweave ANS-104 bundle сервера. -### Блоки могут прийти не по порядку +После того как AR.IO gateway распаковал/indexed bundle, child DataItem присутствует в GraphQL как отдельная сущность со своим `id` и собственными tags. -Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается. +### Получение полного signed DataItem -## Дедупликация и несколько серверов +Обычная выдача DataItem по gateway URL может представлять только payload, а SHiNE для криптографической проверки нужны полные serialized ANS-104 bytes. -Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален. +Поэтому importer: -Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами. +1. получает `data_item_id` через GraphQL `App=test5590`; +2. запрашивает `GET /ar-io/offsets/{data_item_id}`; +3. получает `rootTxId`, `rootOffset`, `size`; +4. делает range-read `GET /raw/{rootTxId}` ровно по этому диапазону; +5. разбирает полученные bytes как `Ans104DataItem`; +6. проверяет, что `SHA-256(signature) == data_item_id`; +7. проверяет Ed25519 ANS-104 signature; +8. определяет пользователя по `owner`; +9. импортирует через обычную логику `AddBlock` без повторной публикации. + +Если GraphQL уже увидел DataItem, но gateway ещё не подготовил offsets, checkpoint не продвигается за этот height и DataItem будет повторён в следующем цикле. + +## Очередь и порядок блоков + +`arweave_block_import_queue` хранит: + +- `data_item_id`; +- `block_height`; +- полный `raw_data_item`; +- status/error/timestamps. + +`root_tx_id` очереди больше не нужен. + +Если block N+1 увиден раньше N, он остаётся `PENDING`; после появления предыдущего блока очередь повторно проигрывается. + +## Дедупликация + +`blocks.data_item_id` уникален. Один signed DataItem остаётся одним логическим SHiNE-блоком независимо от того, сколько серверов или bundler-ов физически включили его в Arweave. + +Импортированный блок записывается через `AddBlock` с отключённой повторной публикацией, поэтому серверы не создают цикл переархивирования. ## Локальное хранение -Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL. - -## Настройки - -См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`. - -## Что намеренно не входит в этот патч - -Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key. +Полный serialized signed DataItem хранится в `blocks.block_bytes` PostgreSQL. Пользовательские `.bch`-файлы не являются источником истины. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index a0e37e0e..bc247cb5 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,13 @@ # История изменений документации блокчейна +## 2026-09-23 — Turbo transport для individual ANS-104 DataItems +- Базовый коммит-ориентир: `3483a0a`; изменения подготовлены как patch без нового git-коммита. +- Publisher получил режимы `turbo | arweave | none`: Turbo отправляет каждый исходный user-signed DataItem отдельно, direct Arweave fallback сохраняет standard ANS-104 bundle, `none` отключает внешнюю публикацию. +- Удалён технический namespace `App=test5590-batch`: importer всегда ищет только individual `App=test5590` DataItems независимо от способа их физической упаковки. +- Channel tag тестового контура изменён с `c` на `c_test5590`; новый тег является частью пользовательской ANS-104 подписи. +- Importer получает точные serialized signed DataItem bytes через AR.IO offsets + range-read root transaction и проверяет `data_item_id`/Ed25519 signature перед `AddBlock`. +- Из PostgreSQL удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`; добавлена migration v25. + ## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация - Базовый коммит-ориентир: `3483a0a`. - Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`. diff --git a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md index c08e6016..791f37df 100644 --- a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md +++ b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md @@ -1,108 +1,79 @@ -# Инструкция Codex: применить ANS-104 test5590 patch +# Применение patch: Turbo + direct Arweave для `App=test5590` -## Цель +## Что меняется -Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек. +- `arweave.blocks.publish.mode=turbo|arweave|none` вместо boolean publisher switch. +- `turbo`: каждый готовый user-signed ANS-104 DataItem отправляется в Turbo отдельно. +- `arweave`: сохранён прямой L1 fallback — несколько user DataItems собираются в standard ANS-104 bundle. +- `none`: наружу ничего не публикуется. +- Importer всегда ищет только individual `App=test5590` DataItems. +- `App=test5590-batch` больше не используется. +- Channel tag: `c_test5590=` вместо `c=...`. +- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`. +- Схема PostgreSQL: v25. -## Жёсткое ограничение - -**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`. - -Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch. - -## Применение - -1. Распаковать patch поверх корня репозитория, сохраняя относительные пути. -2. Удалить все пути из корневого `DELETE_FILES.txt`. -3. Проверить, что `git diff -- ESP32` пуст. -4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется. - -## Arweave config - -Минимально для публикации: +## Минимальная настройка Turbo ```properties -arweave.blocks.publish.enabled=true -arweave.blocks.publish.intervalMinutes=15 -arweave.blocks.publish.gateway=https://arweave.net -arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json +arweave.blocks.publish.mode=turbo +arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx ``` -JWK не коммитить. +Для действующего free tier маленьких DataItem этого может быть достаточно. -Для discovery/import: +Если upload платный и расходы должны идти с server Turbo Credits: + +```properties +arweave.blocks.publish.turbo.walletJwkPath=/home/player/SHiNE/secrets/turbo-wallet.json +# либо вместо JWK сразу публичный адрес: +# arweave.blocks.publish.turbo.paidByAddress= +``` + +JWK не отправляется Turbo: из него вычисляется публичный address для `x-paid-by`. +Для чужого signed DataItem платные Turbo Credits требуют действующего Credit Share Approval от server payer к signer-адресу DataItem. Если его нет, Turbo вернёт HTTP 402, а блок останется pending для повторной попытки. + +## Direct Arweave fallback + +```properties +arweave.blocks.publish.mode=arweave +arweave.blocks.publish.walletJwkPath=/home/player/SHiNE/secrets/arweave-wallet.json +arweave.blocks.publish.gateway=https://arweave.net +``` + +Root bundle больше не получает `App=test5590-batch`; child DataItems уже содержат `App=test5590` и именно их индексирует importer. + +## Отключение публикации + +```properties +arweave.blocks.publish.mode=none +``` + +Это не отключает `arweave.blocks.sync.enabled`: read/import и publish независимы. + +## Importer ```properties arweave.blocks.sync.enabled=true -arweave.blocks.sync.intervalMinutes=15 arweave.blocks.sync.gateway=https://turbo-gateway.com -arweave.blocks.sync.startBlockHeight=0 +arweave.blocks.sync.maxDataItemBytes=8388608 ``` -На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю. +Importer: -## Test namespace +1. GraphQL `App=test5590`; +2. `/ar-io/offsets/`; +3. range `GET /raw/`; +4. проверка exact signed DataItem ID + signature; +5. обычный `AddBlock` import. -Child DataItem: +## Миграция БД -```text -App=test5590 -``` +При старте schema v24 автоматически применит `migration_v25.sql`, которая удаляет два root-tx поля и ставит version 25. -Channel child: +## Проверка после применения -```text -App=test5590 -c= -``` - -Root bundle: - -```text -Bundle-Format=binary -Bundle-Version=2.0.0 -App=test5590-batch -``` - -Перед production-start test namespace должен быть заменён отдельным осознанным изменением. - -## Проверки после применения - -Из корня репозитория: - -```bash -node --check shine-UI/js/services/ans104-data-item.js -node --check shine-UI/js/services/auth-service.js -node --check shine-UI/js/app.js -node --check shine-UI/js/pages/settings-view.js -``` - -Java/Gradle: - -```bash -./gradlew testClasses -./gradlew test -``` - -Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`. - -В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде. - -## Smoke scenario - -1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key. -2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен. -3. Создать channel и post; проверить `c=`. -4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx. -5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке. -6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`. -7. Проверить, что повторный discovery не создаёт дублей. - -## Не делать в этом patch - -- не добавлять backward compatibility Frame v0; -- не возвращать `.bch` storage; -- не возвращать SHINE-ARCHIVE; -- не менять ESP32; -- не мигрировать remote/homeserver signing без отдельного решения пользователя; -- не заменять `prevHash` на Arweave DataItem ID. +1. Создать новый channel/post и проверить signed tag `c_test5590=`. +2. В `mode=turbo` убедиться, что `blocks.data_item_id` совпадает с Turbo response `id` и pending становится false. +3. На втором сервере включить sync и убедиться, что DataItem находится GraphQL-запросом `App=test5590` и импортируется без прямой server-to-server связи. +4. Переключить первый сервер в `mode=arweave`, создать ещё несколько блоков и убедиться, что тот же importer второго сервера видит child DataItems без знания root bundle ID. +5. Проверить `mode=none`: новые локальные блоки остаются pending, наружу ничего не отправляется. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index ac552eb6..30928277 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -14,7 +14,7 @@ ```text User -> создаёт Frame v1 - -> tags: App=test5590, при канале c= + -> tags: App=test5590, при канале c_test5590= -> Ed25519 подписывает ANS-104 deep-hash -> готовый DataItem -> AddBlock @@ -22,8 +22,9 @@ User Server -> verify DataItem + SHiNE chain -> PostgreSQL - -> каждые ~15 минут ANS-104 bundle - -> Arweave L1 + -> publish.mode=turbo: каждый signed DataItem через Turbo + ИЛИ publish.mode=arweave: большой standard ANS-104 bundle -> Arweave L1 + ИЛИ publish.mode=none: наружу не публиковать Other servers -> GraphQL App=test5590 diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c8c97879..24859bdd 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1845,7 +1845,7 @@ export class AuthService { const ansTags = [{ name: 'App', value: 'test5590' }]; const cleanChannelSlug = String(channelSlug || '').trim(); - if (cleanChannelSlug) ansTags.push({ name: 'c', value: cleanChannelSlug }); + if (cleanChannelSlug) ansTags.push({ name: 'c_test5590', value: cleanChannelSlug }); const { response, blockchainName } = await this.runAddBlockWithRetry({ login: cleanLogin, diff --git a/shine-UI/static-sites/arweave-viewer/README.md b/shine-UI/static-sites/arweave-viewer/README.md index eccebca9..5f306626 100644 --- a/shine-UI/static-sites/arweave-viewer/README.md +++ b/shine-UI/static-sites/arweave-viewer/README.md @@ -32,7 +32,7 @@ index.html?key=&channel=books 1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. 2. Если передан `key`, использует его напрямую и не обращается к Solana. 3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. -4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; `channel` применяется локально после загрузки авторского блокчейна. 5. Загружает payload каждого DataItem через gateway `/`. 6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). 7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. diff --git a/shine-UI/static-sites/arweave-viewer/index.html b/shine-UI/static-sites/arweave-viewer/index.html index 6e0c4bac..aba22cff 100644 --- a/shine-UI/static-sites/arweave-viewer/index.html +++ b/shine-UI/static-sites/arweave-viewer/index.html @@ -1032,8 +1032,8 @@ select:focus { /* Viewer v3 */ :root{--v3-bg:#f5f5f3;--v3-panel:#fff;--v3-text:#171717;--v3-sub:#747474;--v3-border:#e7e7e3;--v3-hover:#f0f0ed;--v3-shadow:0 18px 60px rgba(0,0,0,.08)} html[data-theme="dark"]{color-scheme:dark;--v3-bg:#101110;--v3-panel:#181918;--v3-text:#f3f3ef;--v3-sub:#9c9d98;--v3-border:#292a28;--v3-hover:#222320;--v3-shadow:0 18px 60px rgba(0,0,0,.28)} -html,body{background:var(--v3-bg)!important;color:var(--v3-text)!important}.viewer-app{min-height:100vh;background:var(--v3-bg);padding-bottom:54px}.viewer-toolbar{position:sticky;top:0;z-index:30;height:64px;padding:0 max(18px,calc((100vw - 980px)/2));display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--v3-bg) 88%,transparent);backdrop-filter:blur(18px);border-bottom:1px solid var(--v3-border)} -.v3-brand{font-weight:800;letter-spacing:-.03em;margin-right:auto}.v3-tabs{display:flex;padding:4px;background:var(--v3-hover);border-radius:12px}.v3-tab{border:0;background:transparent;color:var(--v3-sub);padding:8px 13px;border-radius:9px;font-weight:700;cursor:pointer}.v3-tab.is-active{background:var(--v3-panel);color:var(--v3-text);box-shadow:0 1px 5px rgba(0,0,0,.08)}.toolbar-icon{background:var(--v3-panel)!important;color:var(--v3-text)!important;border:1px solid var(--v3-border)!important;border-radius:11px!important;width:40px;height:40px}.v3-menu{position:absolute;right:max(18px,calc((100vw - 980px)/2));top:58px;width:min(340px,calc(100vw - 28px));padding:14px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:16px;box-shadow:var(--v3-shadow);z-index:50}.v3-menu label{color:var(--v3-text)}.v3-menu input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-switch{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 2px 14px}.viewer-main{width:min(860px,calc(100% - 28px));margin:22px auto!important}.mobile-card,.message-bubble,.channel-row{background:var(--v3-panel)!important;color:var(--v3-text)!important;border-color:var(--v3-border)!important;box-shadow:none!important}.message-bubble__meta,.channel-row__meta,.channel-row__preview,.channel-head p{color:var(--v3-sub)!important}.thread-toggle{border:0;background:transparent;color:var(--v3-sub);padding:8px 0 2px;font-weight:700;cursor:pointer}.thread-toggle:hover{color:var(--v3-text)}.message-thread__replies{border-left:1px solid var(--v3-border)!important;margin-left:18px!important;padding-left:16px!important}.v3-status{position:fixed;z-index:40;left:0;right:0;bottom:0;height:38px;display:flex;align-items:center;padding:0 max(18px,calc((100vw - 980px)/2));background:var(--v3-panel);border-top:1px solid var(--v3-border);font-size:12px;color:var(--v3-sub);cursor:pointer}.v3-status-detail{position:fixed;z-index:39;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,calc(100% - 28px));max-height:45vh;overflow:auto;background:var(--v3-panel);border:1px solid var(--v3-border);border-bottom:0;border-radius:16px 16px 0 0;padding:16px;box-shadow:var(--v3-shadow);font:12px/1.55 var(--mono);white-space:pre-wrap}.v3-start{max-width:520px;margin:12vh auto;padding:28px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:24px;box-shadow:var(--v3-shadow)}.v3-start h1{margin:0 0 8px;font-size:32px;letter-spacing:-.04em}.v3-start p{color:var(--v3-sub);line-height:1.5}.v3-start-grid{display:grid;gap:12px}.v3-start input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-open{height:48px;border:0;border-radius:12px;background:var(--v3-text);color:var(--v3-bg);font-weight:800;cursor:pointer}.v3-blocks{display:grid;gap:8px}.v3-block{background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:14px;padding:12px 14px}.v3-block summary{cursor:pointer;font-weight:750}.v3-block pre{overflow:auto;color:var(--v3-sub);white-space:pre-wrap}.message-inline-media{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px}.message-inline-media:has(>a:only-child){grid-template-columns:minmax(0,1fr)}.message-inline-media a{display:block;overflow:hidden;border-radius:12px;border:1px solid var(--v3-border);background:var(--v3-bg)}.message-inline-media img{display:block;width:100%;max-height:520px;object-fit:contain;background:var(--v3-bg)}@media(max-width:520px){.message-inline-media{grid-template-columns:1fr}.message-inline-media img{max-height:70vh}} +html,body{background:var(--v3-bg)!important;color:var(--v3-text)!important}.viewer-app{min-height:100vh;background:var(--v3-bg);padding-bottom:54px}.viewer-toolbar{position:sticky;top:0;z-index:30;height:64px;padding:0 max(18px,calc((100vw - 980px)/2));display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--v3-bg) 88%,transparent);backdrop-filter:blur(18px);border-bottom:1px solid var(--v3-border);overflow:visible!important} +.v3-brand{font-weight:800;letter-spacing:-.03em;margin-right:auto}.v3-tabs{display:flex;padding:4px;background:var(--v3-hover);border-radius:12px}.v3-tab{border:0;background:transparent;color:var(--v3-sub);padding:8px 13px;border-radius:9px;font-weight:700;cursor:pointer}.v3-tab.is-active{background:var(--v3-panel);color:var(--v3-text);box-shadow:0 1px 5px rgba(0,0,0,.08)}.toolbar-icon{background:var(--v3-panel)!important;color:var(--v3-text)!important;border:1px solid var(--v3-border)!important;border-radius:11px!important;width:40px;height:40px}.v3-menu{position:absolute;right:max(18px,calc((100vw - 980px)/2));top:58px;width:min(340px,calc(100vw - 28px));padding:14px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:16px;box-shadow:var(--v3-shadow);z-index:1000}.v3-menu label{color:var(--v3-text)}.v3-menu input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-switch{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 2px 14px}.viewer-main{width:min(860px,calc(100% - 28px));margin:22px auto!important}.mobile-card,.message-bubble,.channel-row{background:var(--v3-panel)!important;color:var(--v3-text)!important;border-color:var(--v3-border)!important;box-shadow:none!important}.message-bubble__meta,.channel-row__meta,.channel-row__preview,.channel-head p{color:var(--v3-sub)!important}.thread-toggle{border:0;background:transparent;color:var(--v3-sub);padding:8px 0 2px;font-weight:700;cursor:pointer}.thread-toggle:hover{color:var(--v3-text)}.message-thread__replies{border-left:1px solid var(--v3-border)!important;margin-left:18px!important;padding-left:16px!important}.v3-status{position:fixed;z-index:40;left:0;right:0;bottom:0;height:38px;display:flex;align-items:center;padding:0 max(18px,calc((100vw - 980px)/2));background:var(--v3-panel);border-top:1px solid var(--v3-border);font-size:12px;color:var(--v3-sub);cursor:pointer}.v3-status-detail{position:fixed;z-index:39;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,calc(100% - 28px));max-height:45vh;overflow:auto;background:var(--v3-panel);border:1px solid var(--v3-border);border-bottom:0;border-radius:16px 16px 0 0;padding:16px;box-shadow:var(--v3-shadow);font:12px/1.55 var(--mono);white-space:pre-wrap}.v3-start{max-width:520px;margin:12vh auto;padding:28px;background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:24px;box-shadow:var(--v3-shadow)}.v3-start h1{margin:0 0 8px;font-size:32px;letter-spacing:-.04em}.v3-start p{color:var(--v3-sub);line-height:1.5}.v3-start-grid{display:grid;gap:12px}.v3-start input{background:var(--v3-bg);color:var(--v3-text);border-color:var(--v3-border)}.v3-open{height:48px;border:0;border-radius:12px;background:var(--v3-text);color:var(--v3-bg);font-weight:800;cursor:pointer}.v3-blocks{display:grid;gap:8px}.v3-block{background:var(--v3-panel);border:1px solid var(--v3-border);border-radius:14px;padding:12px 14px}.v3-block summary{cursor:pointer;font-weight:750}.v3-block pre{overflow:auto;color:var(--v3-sub);white-space:pre-wrap}.message-inline-media{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px}.message-inline-media:has(>a:only-child){grid-template-columns:minmax(0,1fr)}.message-inline-media a{display:block;overflow:hidden;border-radius:12px;border:1px solid var(--v3-border);background:var(--v3-bg)}.message-inline-media img{display:block;width:100%;max-height:520px;object-fit:contain;background:var(--v3-bg)}@media(max-width:520px){.message-inline-media{grid-template-columns:1fr}.message-inline-media img{max-height:70vh}} .verification-view{position:fixed;inset:0;z-index:80;background:var(--v3-bg);color:var(--v3-text);overflow:auto;padding:0 0 70px} .verification-head{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:12px;padding:14px max(16px,calc((100vw - 900px)/2));background:var(--v3-panel);border-bottom:1px solid var(--v3-border)} .verification-head h2{margin:0;font-size:18px}.verification-body{width:min(900px,calc(100% - 28px));margin:20px auto;display:grid;gap:14px} @@ -2288,7 +2288,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x async function* queryArweaveChannelPages(gatewayBaseUrl, blockchainKey, channelName) { const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); - // Author-first: channel is a local view filter, never an Arweave query filter. + // Author-first and transport-agnostic (Turbo/direct Arweave): discover user DataItems by owner + App=test5590; channel/c_test5590 is only a local view filter. const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; diff --git a/shine-solana-arweave-viewer-v2/README.md b/shine-solana-arweave-viewer-v2/README.md index 21236b1a..48c54e9c 100644 --- a/shine-solana-arweave-viewer-v2/README.md +++ b/shine-solana-arweave-viewer-v2/README.md @@ -32,7 +32,7 @@ index.html?key=&channel=books 1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. 2. Если передан `key`, использует его напрямую и не обращается к Solana. 3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. -4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=`. 5. Загружает payload каждого DataItem через gateway `/`. 6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). 7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. @@ -40,6 +40,6 @@ index.html?key=&channel=books ## Важно -При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c_test5590`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. diff --git a/shine-solana-arweave-viewer-v2/index.html b/shine-solana-arweave-viewer-v2/index.html index 9a598cc7..42f16135 100644 --- a/shine-solana-arweave-viewer-v2/index.html +++ b/shine-solana-arweave-viewer-v2/index.html @@ -2272,7 +2272,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; - if (channelName) tags.push({ name: 'c', values: [channelName] }); + if (channelName) tags.push({ name: 'c_test5590', values: [channelName] }); const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_ASC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; const items = []; diff --git a/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md index 652117f7..26b2ecaa 100644 --- a/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md +++ b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md @@ -6,7 +6,7 @@ Viewer рассчитан на новый архив SHiNE: **один поль `?login=&channel=` -Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c=`. +Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c_test5590=`. ### Режим 2: key + channel diff --git a/shine-solana-arweave-viewer-v3/README.md b/shine-solana-arweave-viewer-v3/README.md index eccebca9..4e9c5523 100644 --- a/shine-solana-arweave-viewer-v3/README.md +++ b/shine-solana-arweave-viewer-v3/README.md @@ -32,7 +32,7 @@ index.html?key=&channel=books 1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. 2. Если передан `key`, использует его напрямую и не обращается к Solana. 3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. -4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c=`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=`. 5. Загружает payload каждого DataItem через gateway `/`. 6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). 7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. @@ -40,7 +40,7 @@ index.html?key=&channel=books ## Важно -При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c_test5590`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. diff --git a/shine-solana-arweave-viewer-v3/index.html b/shine-solana-arweave-viewer-v3/index.html index 446463c4..9d1a5efc 100644 --- a/shine-solana-arweave-viewer-v3/index.html +++ b/shine-solana-arweave-viewer-v3/index.html @@ -2285,7 +2285,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x const gateway = baseUrl(gatewayBaseUrl); const ownerAddress = await arweaveOwnerAddressFromBlockchainKey(blockchainKey); const tags = [{ name: 'App', values: [SHINE_ARWEAVE_APP_TAG] }]; - if (channelName) tags.push({ name: 'c', values: [channelName] }); + if (channelName) tags.push({ name: 'c_test5590', values: [channelName] }); const query = `query($owners:[String!],$tags:[TagFilter!],$after:String){transactions(first:100,after:$after,owners:$owners,tags:$tags,sort:HEIGHT_DESC){pageInfo{hasNextPage}edges{cursor node{id owner{address key} block{height timestamp} bundledIn{id} tags{name value}}}}}`; let after = null; let page = 0;