SHA256
Новая схема работы Arweave через Turbo
This commit is contained in:
+2
-2
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
+44
-10
@@ -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<BlockEntry> 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<BlockEntry> 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<BlockEntry> candidates) throws Exception {
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> 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<ArweaveL1Uploader.Tag> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+102
-32
@@ -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<byte[]> 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<InputStream> 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<String> 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("\"","\\\"");}
|
||||
}
|
||||
|
||||
+41
-8
@@ -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;}
|
||||
|
||||
+106
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -16,7 +16,7 @@ final class Ans104DataItemTest {
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> 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<Ans104DataItem.Tag> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -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<QueueItem> 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")));
|
||||
}
|
||||
|
||||
@@ -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<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException {
|
||||
public void markArweavePublished(List<byte[]> 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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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=<slug>.
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
|
||||
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);
|
||||
|
||||
@@ -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=<canonical-channel-slug>.
|
||||
# Channel DataItems additionally contain c_test5590=<canonical-channel-slug>.
|
||||
# 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
|
||||
|
||||
@@ -82,7 +82,7 @@ public final class AddBlockSender {
|
||||
List<Ans104DataItem.Tag> 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);
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.23
|
||||
server.version=1.10.9
|
||||
client.version=1.12.24
|
||||
server.version=1.10.10
|
||||
|
||||
@@ -82,7 +82,7 @@ App = test5590
|
||||
Если блок относится к конкретному каналу, он дополнительно содержит:
|
||||
|
||||
```text
|
||||
c = <canonical_channel_slug>
|
||||
c_test5590 = <canonical_channel_slug>
|
||||
```
|
||||
|
||||
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;
|
||||
|
||||
@@ -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=<canonical_channel_slug>
|
||||
c_test5590=<canonical_channel_slug>
|
||||
```
|
||||
|
||||
Теги входят в 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`-файлы не являются источником истины.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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=<canonical_channel_slug>` вместо `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=<server payer address>
|
||||
```
|
||||
|
||||
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/<dataItemId>`;
|
||||
3. range `GET /raw/<rootTxId>`;
|
||||
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=<canonical_channel_slug>
|
||||
```
|
||||
|
||||
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=<canonical slug>`.
|
||||
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=<canonical slug>`.
|
||||
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, наружу ничего не отправляется.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
```text
|
||||
User
|
||||
-> создаёт Frame v1
|
||||
-> tags: App=test5590, при канале c=<slug>
|
||||
-> tags: App=test5590, при канале c_test5590=<slug>
|
||||
-> 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,7 @@ index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_KEY>&channel=books
|
||||
1. Если передан `login`, вычисляет PDA `user_login=<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=<channel>`.
|
||||
4. Делает Arweave GraphQL запрос по owner + `App=test5590`; `channel` применяется локально после загрузки авторского блокчейна.
|
||||
5. Загружает payload каждого DataItem через gateway `/<DataItemId>`.
|
||||
6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes).
|
||||
7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,7 +32,7 @@ index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_KEY>&channel=books
|
||||
1. Если передан `login`, вычисляет PDA `user_login=<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=<channel>`.
|
||||
4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=<channel>`.
|
||||
5. Загружает payload каждого DataItem через gateway `/<DataItemId>`.
|
||||
6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes).
|
||||
7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1.
|
||||
@@ -40,6 +40,6 @@ index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_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/`.
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -6,7 +6,7 @@ Viewer рассчитан на новый архив SHiNE: **один поль
|
||||
|
||||
`?login=<login>&channel=<slug>`
|
||||
|
||||
Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c=<slug>`.
|
||||
Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c_test5590=<slug>`.
|
||||
|
||||
### Режим 2: key + channel
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_KEY>&channel=books
|
||||
1. Если передан `login`, вычисляет PDA `user_login=<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=<channel>`.
|
||||
4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=<channel>`.
|
||||
5. Загружает payload каждого DataItem через gateway `/<DataItemId>`.
|
||||
6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes).
|
||||
7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1.
|
||||
@@ -40,7 +40,7 @@ index.html?key=<BASE58_BLOCKCHAIN_PUBLIC_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/`.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user