Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions SEQUENTIA.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,9 @@ The fork is not published to npm; consumers build `pkg/` with `wasm-pack`.
are the transaction's other inputs, `[]` for a coin spent alone. `limits` is
`{ feeFloorPerKvb }` for the specification's fee margin or
`{ maxUncommitted }` for a ceiling in atoms; without it a rebind must commit
the whole coin.
the whole coin. A message, a source, an output or input, or `limits` with a
field it does not name is refused (`unknown field ...`), so a misspelt field
never quietly takes a default.
- `src/ark.rs`: Arca leaves. `arkNewOwnerNonce()`, `arkLeafKeyPath(account,
ownerNonce)`, `Signer.arkLeafKey(account, ownerNonce)` and
`Signer.arkRestoreKey(account, record)` for the leaf keys;
Expand All @@ -459,7 +461,9 @@ The fork is not published to npm; consumers build `pkg/` with `wasm-pack`.
chain's median time `now` as its last argument (the policy names none, and
refuses one), whose verdict is
`{ accepted: true, leafId, roundTxid, asset, value, expiries, ... }` or
`{ accepted: false, failed, check, kind, reason }`; and `ArkStore(storage)`
`{ accepted: false, failed, check, kind, reason }`; a policy with a field
it does not name is refused, as a misspelt bound would otherwise take its
default; and `ArkStore(storage)`
over a `JsStorage` object, with `putPending`, `putLeaf`, `putRestoredLeaf`
and `removeLeaf` keeping the same rules as the native store. A record is its JSON text or its binary form as
hex. Asset ids, the token and the genesis hash are display hex at this
Expand Down
24 changes: 19 additions & 5 deletions lwk_wasm/src/ark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ fn generic(e: impl std::fmt::Display) -> Error {
Error::Generic(e.to_string())
}

/// Read a plain object from JavaScript into `T`, refusing any field `T` does
/// not name, at every level.
///
/// `serde_wasm_bindgen` reads a struct by asking the object for each field the
/// struct names, so a field it does not name is never seen and
/// `deny_unknown_fields` has nothing to refuse: a misspelt bound would quietly
/// take its default. The object is read whole first, every key with it, and
/// then into `T`, where `deny_unknown_fields` applies.
pub(crate) fn from_js<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, Error> {
let whole: serde_json::Value = serde_wasm_bindgen::from_value(value)?;
Ok(serde_json::from_value(whole)?)
}

fn refused(e: &RecordError) -> Error {
Error::Generic(format!("record refused (kind {}): {e}", e.kind()))
}
Expand Down Expand Up @@ -194,8 +207,8 @@ pub fn ark_parse_record(record: &str) -> Result<JsValue, Error> {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PolicyDto {
operator: String,
/// Refused when present: `now` is given to each call. Named here because
/// an object read from JavaScript ignores fields the struct does not name.
/// Refused when present, with its own message: `now` is given to each
/// call.
now: Option<serde::de::IgnoredAny>,
min_notice_seconds: Option<u64>,
horizon_seconds: Option<u32>,
Expand All @@ -207,7 +220,7 @@ struct PolicyDto {
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FeeRateFloorDto {
floor_per_kvb: u64,
multiple: u64,
Expand Down Expand Up @@ -319,7 +332,8 @@ impl VerdictDto {
///
/// The policy object is `{ operator, minNoticeSeconds?, horizonSeconds?,
/// minExitDelaySeconds?, maxExitDelaySeconds?, maxLevels?, minReserveAtoms?,
/// minReserveFeeRate? }`: the operator key the wallet was told (x-only hex)
/// minReserveFeeRate? }`, refusing any field it does not name: the operator key
/// the wallet was told (x-only hex)
/// and the bounds, which default to the specification's (a notice of at least
/// 36 hours, a first expiry at least 27 days after now for a leaf taken from a
/// round, an exit delay of 36 to 48 hours, a path of at most 5 levels, and a
Expand Down Expand Up @@ -358,7 +372,7 @@ impl ArkVerifier {
/// A verifier for `network` with the `policy` object described above.
#[wasm_bindgen(constructor)]
pub fn new(network: &Network, policy: JsValue) -> Result<ArkVerifier, Error> {
let dto: PolicyDto = serde_wasm_bindgen::from_value(policy)?;
let dto: PolicyDto = from_js(policy)?;
if dto.now.is_some() {
return Err(generic(
"the policy takes no `now`: give the chain's median time to each call",
Expand Down
30 changes: 24 additions & 6 deletions lwk_wasm/src/csfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
//! `htlc-refund-both` and `leafId` is the id of the leaf the output is, or
//! was made from.
//!
//! Every object here, the message, its source, outputs and inputs, and the
//! `limits` of `signCsfs`, is refused when it carries a field it does not
//! name, so a misspelt field never quietly takes a default.
//!
//! A rebind's `otherInputs` are the transaction's other inputs, `[]` when the
//! coin is spent alone; for a reassignment, every other checkpoint. What the
//! transaction leaves to whoever broadcasts is reckoned over all of them.
Expand Down Expand Up @@ -64,14 +68,15 @@ impl Atoms {
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct OutputDto {
asset: String,
value: Atoms,
script_pubkey: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InputDto {
asset: String,
value: Atoms,
Expand All @@ -92,15 +97,28 @@ struct NamedSourceDto {
salt: String,
}

#[derive(Deserialize)]
#[serde(untagged)]
/// A source is `{ record }` when it names a record, and the named form
/// otherwise; each refuses fields it does not name, which an untagged enum
/// would report only as matching no variant.
enum SourceDto {
Record(RecordSourceDto),
Named(NamedSourceDto),
}

impl<'de> Deserialize<'de> for SourceDto {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
let source = if v.get("record").is_some() {
serde_json::from_value(v).map(SourceDto::Record)
} else {
serde_json::from_value(v).map(SourceDto::Named)
};
source.map_err(serde::de::Error::custom)
}
}

#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
enum MessageDto {
#[serde(rename_all = "camelCase")]
Rebind {
Expand Down Expand Up @@ -180,7 +198,7 @@ pub(crate) fn parse_limits(limits: JsValue, genesis_hash: BlockHash) -> Result<C
let dto: LimitsDto = if limits.is_undefined() || limits.is_null() {
LimitsDto::default()
} else {
serde_wasm_bindgen::from_value(limits)?
crate::ark::from_js(limits)?
};
Ok(match (dto.fee_floor_per_kvb, dto.max_uncommitted) {
(Some(_), Some(_)) => {
Expand All @@ -196,7 +214,7 @@ pub(crate) fn parse_limits(limits: JsValue, genesis_hash: BlockHash) -> Result<C

/// Parse a message object into the signer's typed message.
pub(crate) fn parse_message(message: JsValue) -> Result<ArcaMessage, Error> {
let dto: MessageDto = serde_wasm_bindgen::from_value(message)?;
let dto: MessageDto = crate::ark::from_js(message)?;
Ok(match dto {
MessageDto::Rebind {
source: src,
Expand Down
10 changes: 10 additions & 0 deletions lwk_wasm/tests/node/arca_signers.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ assert.throws(() => signer.signCsfs('m/6/0', rebindElsewhere, lwk.csfsDigest(reb
/not this wallet's/);
assert.throws(() => lwk.csfsDigest({ ...rebind, source: { ...rebind.source, path: 'exit' } }), /unknown rebindable path/);

// A field a message, a source, an output or the limits do not name is refused,
// not ignored: a misspelt ceiling would otherwise take its default.
assert.throws(() => signer.signCsfs('m/6/0', rebind, d, { maxUncomitted: '9999000' }), /unknown field `maxUncomitted`/);
assert.throws(() => lwk.csfsDigest({ ...rebind, source: { ...rebind.source, salts: '07'.repeat(32) } }), /unknown field `salts`/);
assert.throws(() => lwk.csfsDigest({ ...rebind, source: { record: 'ab', leafId: 'a1'.repeat(32) } }), /unknown field `leafId`/);
assert.throws(() => lwk.csfsDigest({ ...rebind, outputs: [{ ...rebind.outputs[0], amount: 5 }] }), /unknown field `amount`/);
assert.throws(() => lwk.csfsDigest({ ...rebind, otherInputs: [{ asset: X, value: 1, vout: 0 }] }), /unknown field `vout`/);
assert.throws(() => lwk.csfsDigest({ ...unroll, tme: 5 }), /unknown field `tme`/);
assert.throws(() => lwk.csfsDigest({ ...release, connectorAsset: X }), /unknown field `connectorAsset`/);

// What a reassignment leaves is reckoned over every input (review R4, F9):
// a checkpoint of 100,000 atoms over outputs of 150,000 and 10,000.
const spk = (b) => '5120' + b.repeat(32);
Expand Down
4 changes: 4 additions & 0 deletions lwk_wasm/tests/node/ark_records.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ const shallow = verifier({ maxLevels: 1 }).verifyLeaf(dr.json, deep.round.tx, dl
assert.strictEqual(shallow.accepted, false);
assert.ok(/a path of \d+ levels; the wallet accepts 1 at most/.test(shallow.reason), shallow.reason);
assert.throws(() => verifier({ minReserveAtoms: 0, minReserveFeeRate: { floorPerKvb: 1, multiple: 1 } }), /not both/);
// A field the policy does not name is refused, not ignored: a misspelt bound
// would otherwise take its default without a word.
assert.throws(() => verifier({ maxExitDelaySecond: 172800 }), /unknown field `maxExitDelaySecond`/);
assert.throws(() => verifier({ minReserveFeeRate: { floorPerKvb: 1, multiple: 1, mutliple: 4 } }), /unknown field `mutliple`/);

// 2. The refusal vectors, by kind, from both the reader and the verifier.
const any = v.batches[1];
Expand Down
Loading