Архивация в Arweave

This commit is contained in:
AidarKC
2026-09-12 10:17:23 +03:00
parent ef068eca81
commit c3253dceac
48 changed files with 6681 additions and 1227 deletions
@@ -94,16 +94,17 @@ UserPdaRecordV1
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`.
## 6. RecoveryKeyBlock
@@ -359,6 +360,28 @@ TrustedStateBlock
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15.1. ArchiveHeadBlock
Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA.
```text
ArchiveHeadBlock
- block_type: u8 = 100
- block_version: u8 = 0
- archive_tx_id: [u8; 32]
- archive_hash: [u8; 32]
```
Семантика:
- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA;
- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`;
- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head;
- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**;
- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет.
`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи.
## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
@@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
@@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
- unsigned-часть новой записи подписана `root_key`;
- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`;
- лимиты блокчейнов могут только увеличиваться;
- занятый размер и номер последнего блока не могут уменьшаться;
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
@@ -753,3 +753,23 @@ signature = Ed25519(blockchain_private_key, message_hash)
- Anchor discriminator'ы и Anchor-ABI инструкций;
- старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet;
- внутренние helper-функции старой реализации.
## ArchiveHeadBlock и серверный SHINE-ARCHIVE
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
```text
archive_tx_id [32]
archive_hash [32]
```
Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`.
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
- legacy payload без archive extension сохраняет старый block `100`;
- новый payload может заменить/очистить archive head;
- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью.
Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а.
@@ -36,6 +36,7 @@ const BLOCK_TYPE_SERVER_PROFILE: u8 = 30;
const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40;
const BLOCK_TYPE_SESSIONS: u8 = 50;
const BLOCK_TYPE_TRUSTED_STATE: u8 = 70;
const BLOCK_TYPE_ARCHIVE_HEAD: u8 = 100;
const BLOCK_VERSION_0: u8 = 0;
const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1;
const SESSIONS_MODE_MIXED: u8 = 1;
@@ -163,6 +164,8 @@ pub struct UpdateUserPdaArgs {
pub prev_hash: [u8; 32],
pub additional_limit: u64,
pub fields: UserMutableFields,
/// None = legacy instruction, archive head сохранить; Some(None) = очистить; Some(Some) = заменить.
pub archive_head_update: Option<Option<ArchiveHeadRecord>>,
pub signature: [u8; 64],
}
@@ -210,6 +213,12 @@ pub struct BlockchainRecord {
pub arweave_tx_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArchiveHeadRecord {
pub arweave_tx_id: [u8; 32],
pub archive_hash: [u8; 32],
}
#[derive(Clone, Debug)]
pub struct UserRecord {
pub created_at_ms: u64,
@@ -230,6 +239,7 @@ pub struct UserRecord {
pub sessions_mode: u8,
pub sessions: Vec<SessionRecord>,
pub trusted_count: u8,
pub archive_head: Option<ArchiveHeadRecord>,
pub signature: [u8; 64],
}
@@ -349,17 +359,34 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result<CreateUserPdaArgs, ProgramErr
}
fn parse_update_args(r: &mut Reader<'_>) -> Result<UpdateUserPdaArgs, ProgramError> {
let login = r.read_string_u8()?;
let recovery_key = r.read_pubkey()?;
let root_key = r.read_pubkey()?;
let created_at_ms = r.read_u64()?;
let updated_at_ms = r.read_u64()?;
let version = r.read_u32()?;
let prev_hash = r.read_fixed_32()?;
let additional_limit = r.read_u64()?;
let fields = parse_fields(r)?;
// Backward compatibility: legacy update после trusted_count содержит ровно 64-byte root signature.
let archive_head_update = if r.remaining() > 64 {
let present = r.read_u8()?;
match present {
0 => Some(None),
1 => Some(Some(ArchiveHeadRecord {
arweave_tx_id: r.read_fixed_32()?,
archive_hash: r.read_fixed_32()?,
})),
_ => return Err(ProgramError::from(ShineUsersError::InvalidInstruction)),
}
} else {
None
};
Ok(UpdateUserPdaArgs {
login: r.read_string_u8()?,
recovery_key: r.read_pubkey()?,
root_key: r.read_pubkey()?,
created_at_ms: r.read_u64()?,
updated_at_ms: r.read_u64()?,
version: r.read_u32()?,
prev_hash: r.read_fixed_32()?,
additional_limit: r.read_u64()?,
fields: parse_fields(r)?,
signature: r.read_fixed_64()?,
login, recovery_key, root_key, created_at_ms, updated_at_ms, version, prev_hash,
additional_limit, fields, archive_head_update, signature: r.read_fixed_64()?,
})
}
@@ -590,6 +617,7 @@ fn process_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<'
sessions_mode: args.fields.sessions_mode,
sessions: args.fields.sessions,
trusted_count: args.fields.trusted_count,
archive_head: None,
signature: [0; 64],
};
validate_blockchain_limits(&record.blockchain, 0, 0, true)?;
@@ -713,6 +741,10 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba
sessions_mode: args.fields.sessions_mode,
sessions: args.fields.sessions.clone(),
trusted_count: args.fields.trusted_count,
archive_head: match &args.archive_head_update {
None => old_record.archive_head.clone(),
Some(value) => value.clone(),
},
signature: [0; 64],
})
}
@@ -857,6 +889,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord, ProgramError> {
let mut sessions_mode = SESSIONS_MODE_MIXED;
let mut sessions = Vec::new();
let mut trusted_count = 0u8;
let mut archive_head: Option<ArchiveHeadRecord> = None;
for _ in 0..blocks_count {
let block_type = read_u8_from(useful, &mut cursor)?;
@@ -907,6 +940,13 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord, ProgramError> {
BLOCK_TYPE_TRUSTED_STATE => {
trusted_count = read_u8_from(useful, &mut cursor)?;
}
BLOCK_TYPE_ARCHIVE_HEAD => {
require!(archive_head.is_none(), ShineUsersError::InvalidRecordData);
archive_head = Some(ArchiveHeadRecord {
arweave_tx_id: read_fixed_32_from(useful, &mut cursor)?,
archive_hash: read_fixed_32_from(useful, &mut cursor)?,
});
}
_ => return Err(ProgramError::from(ShineUsersError::InvalidRecordFormat)),
}
}
@@ -933,6 +973,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord, ProgramError> {
sessions_mode,
sessions,
trusted_count,
archive_head,
signature,
})
}
@@ -986,7 +1027,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>, ProgramErro
out.push(login_bytes.len() as u8);
out.extend_from_slice(login_bytes);
let blocks_count = if record.is_server { 8 } else { 7 };
let blocks_count = (if record.is_server { 8 } else { 7 }) + if record.archive_head.is_some() { 1 } else { 0 };
out.push(blocks_count);
write_recovery_key_block(&mut out, record);
write_root_key_block(&mut out, record);
@@ -996,6 +1037,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>, ProgramErro
write_access_servers_block(&mut out, record)?;
write_sessions_block(&mut out, record)?;
write_trusted_state_block(&mut out, record);
if let Some(archive_head) = &record.archive_head { write_archive_head_block(&mut out, archive_head); }
let record_len = out.len().checked_add(64).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?;
require!(record_len <= u16::MAX as usize, ShineUsersError::RecordTooLarge);
@@ -1045,6 +1087,7 @@ fn write_sessions_block(out: &mut Vec<u8>, record: &UserRecord) -> Result<(), Pr
out.push(BLOCK_TYPE_SESSIONS); out.push(BLOCK_VERSION_0); out.push(record.sessions_mode); require!(record.sessions.len() <= MAX_SESSIONS, ShineUsersError::InvalidRecordData); out.push(record.sessions.len() as u8); for session in &record.sessions { out.push(session.session_type); out.push(session.session_version); write_len_prefixed_string(out, &session.session_name)?; out.extend_from_slice(session.session_pub_key.as_ref()); } Ok(())
}
fn write_trusted_state_block(out: &mut Vec<u8>, record: &UserRecord) { out.push(BLOCK_TYPE_TRUSTED_STATE); out.push(BLOCK_VERSION_0); out.push(record.trusted_count); }
fn write_archive_head_block(out: &mut Vec<u8>, archive: &ArchiveHeadRecord) { out.push(BLOCK_TYPE_ARCHIVE_HEAD); out.push(BLOCK_VERSION_0); out.extend_from_slice(&archive.arweave_tx_id); out.extend_from_slice(&archive.archive_hash); }
fn write_len_prefixed_string(out: &mut Vec<u8>, value: &str) -> Result<(), ProgramError> { let bytes = value.as_bytes(); require!(bytes.len() <= u8::MAX as usize, ShineUsersError::InvalidRecordData); out.push(bytes.len() as u8); out.extend_from_slice(bytes); Ok(()) }
fn verify_record_signature_hash(instructions_sysvar: &AccountInfo, root_key: &Pubkey, signature: &[u8; 64], message_hash: &[u8]) -> Result<[u8; 64], ProgramError> {