diff --git a/packages/rs-dpp/src/data_contract/config/mod.rs b/packages/rs-dpp/src/data_contract/config/mod.rs index f44b2b20ebc..7d71684d594 100644 --- a/packages/rs-dpp/src/data_contract/config/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/mod.rs @@ -4,6 +4,8 @@ pub mod moderation; pub mod v0; pub mod v1; pub mod v2; +#[cfg(all(test, feature = "json-conversion"))] +mod vectors; use crate::data_contract::config::moderation::ContractModerationConfig; use crate::data_contract::config::v1::{ diff --git a/packages/rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json b/packages/rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json new file mode 100644 index 00000000000..dd8d632fe73 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json @@ -0,0 +1,957 @@ +{ + "description": "Canonical contract configuration vectors generated by rs-dpp (packages/rs-dpp/src/data_contract/config/vectors). Every mirror decodes `contract` at `platformVersion`, must observe `expect`, and must re-serialize to `canonical`. Regenerate with DASH_WRITE_CONTRACT_CONFIG_VECTORS=1 cargo test -p dpp data_contract::config::vectors", + "cases": [ + { + "name": "v1_defaults", + "platformVersion": 13, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "1", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "1", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "1", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true + } + }, + { + "name": "v1_all_set", + "platformVersion": 13, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "1", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "1", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "1", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false + } + }, + { + "name": "v0_defaults_in_v1_envelope", + "platformVersion": 13, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + } + }, + { + "name": "v0_key_requirements", + "platformVersion": 13, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": 1, + "requiresIdentityDecryptionBoundedKey": 0 + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": 1, + "requiresIdentityDecryptionBoundedKey": 0 + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": 1, + "requiresIdentityDecryptionBoundedKey": 0 + } + }, + { + "name": "v0_envelope", + "platformVersion": 8, + "contract": { + "$formatVersion": "0", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + } + }, + "canonical": { + "$formatVersion": "0", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + } + }, + "expect": { + "$formatVersion": "0", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null + } + }, + { + "name": "v1_unknown_field_is_ignored", + "platformVersion": 13, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "1", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false, + "futurePolicy": { + "kind": 1 + } + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "1", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "1", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false + } + }, + { + "name": "v2_defaults", + "platformVersion": 14, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true + } + }, + { + "name": "v2_moderated", + "platformVersion": 14, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "2", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false, + "moderation": { + "banlist": true, + "suspensions": true, + "moderators": { + "$type": "contractOwner" + }, + "warnings": true + } + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "2", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false, + "moderation": { + "banlist": true, + "suspensions": true, + "moderators": { + "$type": "contractOwner" + }, + "warnings": true + } + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "2", + "canBeDeleted": true, + "readonly": true, + "keepsHistory": true, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": false, + "documentsCanBeDeletedContractDefault": false, + "requiresIdentityEncryptionBoundedKey": 0, + "requiresIdentityDecryptionBoundedKey": 2, + "sizedIntegerTypes": false, + "moderation": { + "banlist": true, + "suspensions": true, + "moderators": { + "$type": "contractOwner" + }, + "warnings": true + } + } + }, + { + "name": "v2_elected", + "platformVersion": 14, + "contract": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true, + "moderation": { + "banlist": true, + "suspensions": false, + "moderators": { + "$type": "elected", + "joinWindow": 86400, + "voteWindow": 172800, + "seatContestable": true, + "challengeCoolDown": 1209600, + "electionDelay": 3600, + "maxAddedModerators": 2, + "moderatedDocumentTypes": { + "note": [ + "ban", + "warn" + ] + }, + "interim": { + "$type": "notYetUsable" + }, + "ownerProtected": true + }, + "warnings": true + } + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "canonical": { + "$formatVersion": "1", + "id": "29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2", + "config": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true, + "moderation": { + "banlist": true, + "suspensions": false, + "moderators": { + "$type": "elected", + "joinWindow": 86400, + "voteWindow": 172800, + "seatContestable": true, + "challengeCoolDown": 1209600, + "electionDelay": 3600, + "maxAddedModerators": 2, + "moderatedDocumentTypes": { + "note": [ + "ban", + "warn" + ] + }, + "interim": { + "$type": "notYetUsable" + }, + "ownerProtected": true + }, + "warnings": true + } + }, + "version": 1, + "ownerId": "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3", + "schemaDefs": null, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "createdAt": null, + "updatedAt": null, + "createdAtBlockHeight": null, + "updatedAtBlockHeight": null, + "createdAtEpoch": null, + "updatedAtEpoch": null, + "groups": {}, + "tokens": {}, + "keywords": [], + "description": null + }, + "expect": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "requiresIdentityEncryptionBoundedKey": null, + "requiresIdentityDecryptionBoundedKey": null, + "sizedIntegerTypes": true, + "moderation": { + "banlist": true, + "suspensions": false, + "moderators": { + "$type": "elected", + "joinWindow": 86400, + "voteWindow": 172800, + "seatContestable": true, + "challengeCoolDown": 1209600, + "electionDelay": 3600, + "maxAddedModerators": 2, + "moderatedDocumentTypes": { + "note": [ + "ban", + "warn" + ] + }, + "interim": { + "$type": "notYetUsable" + }, + "ownerProtected": true + }, + "warnings": true + } + } + } + ] +} diff --git a/packages/rs-dpp/src/data_contract/config/vectors/mod.rs b/packages/rs-dpp/src/data_contract/config/vectors/mod.rs new file mode 100644 index 00000000000..671a69844e9 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/config/vectors/mod.rs @@ -0,0 +1,696 @@ +//! Canonical contract configuration vectors. +//! +//! `contract_config_vectors.json` is the one corpus every client mirror of +//! [`DataContractConfig`] replays. The cases are generated from the Rust +//! types by the tests in this module and pinned in the repository; each +//! mirror decodes the same file, must observe the same typed values +//! (`expect`), and must render the same canonical envelope (`canonical`). +//! +//! The surfaces that mirror the configuration wire shape are listed in +//! [`MIRROR_SURFACES`]. A change to the configuration types (a new +//! generation selected by a protocol version, a field added to or renamed in +//! a shipped generation) fails the guards below until the corpus carries a +//! case for it, and the failure message names every mirror that has to move +//! with it. +//! +//! Regenerate the corpus with +//! +//! ```text +//! DASH_WRITE_CONTRACT_CONFIG_VECTORS=1 cargo test -p dpp data_contract::config::vectors +//! ``` +//! +//! and extend every mirror in the same change, or stack the mirror change on +//! it: the mobile CI jobs replay this file. + +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, +}; +use crate::data_contract::config::v0::{DataContractConfigGettersV0, DataContractConfigV0}; +use crate::data_contract::config::v1::{DataContractConfigGettersV1, DataContractConfigV1}; +use crate::data_contract::config::v2::{DataContractConfigGettersV2, DataContractConfigV2}; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::conversion::json::DataContractJsonConversionMethodsV0; +use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; +use crate::data_contract::serialized_version::v1::DataContractInSerializationFormatV1; +use crate::data_contract::serialized_version::DataContractInSerializationFormat; +use crate::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; +use crate::data_contract::DataContract; +use crate::version::{PlatformVersion, TryIntoPlatformVersioned, ALL_VERSIONS}; +use platform_value::{platform_value, Identifier, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value as JsonValue}; +use std::collections::{BTreeMap, BTreeSet}; + +/// The pinned corpus, read at compile time. +const CORPUS: &str = include_str!("contract_config_vectors.json"); + +/// Where the writer puts a regenerated corpus. +const CORPUS_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/data_contract/config/vectors/contract_config_vectors.json" +); + +/// Set this variable to rewrite the corpus instead of asserting against it. +const WRITE_ENV: &str = "DASH_WRITE_CONTRACT_CONFIG_VECTORS"; + +/// Every surface that models the configuration wire shape. +const MIRROR_SURFACES: &str = "\ + - packages/rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json (this corpus) + - packages/rs-sdk-ffi/src/data_contract/json.rs (the FFI JSON emitters and their vector test) + - the DataContractConfig TypeScript union in packages/wasm-dpp2/src/data_contract/model.rs + and packages/wasm-dpp2/tests/unit/DataContractConfigVectors.spec.ts + - DataContractConfig in packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPDataContract.swift + and the parser projection in packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift + - packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/contracts/DataContractConfig.kt + and the entity projection in the example app's ContractDownloader.kt + - docs/sdk/sdk-parity-manifest.json (capability contract.config_mirror)"; + +/// The protocol version the V1-configuration cases are generated at: the +/// last version whose tables create a V1 configuration by default (the V1 +/// contract envelope and the V1 configuration have been the defaults since +/// protocol version 9). Pinning one version keeps the corpus stable across +/// protocol bumps, while the generation guard below walks every version. +const V1_CONFIG_PROTOCOL_VERSION: u32 = 13; + +/// The protocol version the V2-configuration cases are generated at: the +/// first version whose tables create a V2 configuration (the optional +/// contract moderation declaration) by default. +const V2_CONFIG_PROTOCOL_VERSION: u32 = 14; + +/// The protocol version the historic V0-envelope case is generated at and +/// replayed by every mirror: the last version whose tables still select the +/// V0 contract envelope. +const V0_ENVELOPE_PROTOCOL_VERSION: u32 = 8; + +const CONTRACT_ID: [u8; 32] = [0x11; 32]; +const OWNER_ID: [u8; 32] = [0x22; 32]; + +#[derive(Serialize, Deserialize, Debug)] +struct Corpus { + description: String, + cases: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Case { + name: String, + /// The protocol version the case was generated at; every mirror decodes + /// and re-encodes the case at this version. + platform_version: u32, + /// The contract a mirror decodes. Equals `canonical` except for the + /// unknown-key case, which carries an extra configuration key. + contract: JsonValue, + /// The contract as Rust re-serializes it at `platform_version`. + canonical: JsonValue, + /// The configuration a mirror must observe after decoding `contract`, + /// keyed by the wire names, the format tag included. A key present here + /// that a mirror does not model fails that mirror's test. + expect: JsonValue, +} + +/// One generated case: the configuration under test and the version whose +/// tables choose the contract envelope it is written in. +struct CaseSpec { + name: &'static str, + platform_version: u32, + config: DataContractConfig, + /// Keys added to the `config` block of `contract` only; the canonical + /// rendering must drop them. + unknown_config_keys: Vec<(&'static str, JsonValue)>, +} + +fn v1_all_set() -> DataContractConfigV1 { + DataContractConfigV1 { + can_be_deleted: true, + readonly: true, + keeps_history: true, + documents_keep_history_contract_default: true, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: Some(StorageKeyRequirements::Unique), + requires_identity_decryption_bounded_key: Some( + StorageKeyRequirements::MultipleReferenceToLatest, + ), + sized_integer_types: false, + } +} + +/// A V2 configuration declaring moderation: all three lists kept by the +/// owner alone. The moderators are the owner (no appointed identities) so +/// the case carries no identifier, which every mirror renders differently +/// in object form and in JSON form. +fn v2_moderated() -> DataContractConfigV2 { + DataContractConfigV2 { + moderation: Some(ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: true, + moderators: ContractModerators::ContractOwner, + }), + ..v1_all_set().into() + } +} + +/// A V2 configuration whose moderators are an elected team: every key of +/// the declaration set, the interim moderators a kind without identities, +/// and the one document type of the corpus contract moderated with the +/// abilities the kept lists back. The largest moderation wire shape. +fn v2_elected() -> DataContractConfigV2 { + DataContractConfigV2 { + moderation: Some(ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: true, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: 86_400, + vote_window: 172_800, + challenge_cool_down: Some(1_209_600), + election_delay: Some(3_600), + max_added_moderators: 2, + moderated_document_types: BTreeMap::from([( + "note".to_string(), + BTreeSet::from([ModerationAbility::Ban, ModerationAbility::Warn]), + )]), + interim: InterimModerators::NotYetUsable, + owner_protected: true, + })), + }), + ..DataContractConfigV2::default() + } +} + +fn specs() -> Vec { + vec![ + CaseSpec { + name: "v1_defaults", + platform_version: V1_CONFIG_PROTOCOL_VERSION, + config: DataContractConfigV1::default().into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v1_all_set", + platform_version: V1_CONFIG_PROTOCOL_VERSION, + config: v1_all_set().into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v0_defaults_in_v1_envelope", + platform_version: V1_CONFIG_PROTOCOL_VERSION, + config: DataContractConfigV0::default().into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v0_key_requirements", + platform_version: V1_CONFIG_PROTOCOL_VERSION, + config: DataContractConfigV0 { + requires_identity_encryption_bounded_key: Some(StorageKeyRequirements::Multiple), + requires_identity_decryption_bounded_key: Some(StorageKeyRequirements::Unique), + ..Default::default() + } + .into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v0_envelope", + platform_version: V0_ENVELOPE_PROTOCOL_VERSION, + config: DataContractConfigV0::default().into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v1_unknown_field_is_ignored", + platform_version: V1_CONFIG_PROTOCOL_VERSION, + config: v1_all_set().into(), + unknown_config_keys: vec![("futurePolicy", json!({ "kind": 1 }))], + }, + CaseSpec { + name: "v2_defaults", + platform_version: V2_CONFIG_PROTOCOL_VERSION, + config: DataContractConfigV2::default().into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v2_moderated", + platform_version: V2_CONFIG_PROTOCOL_VERSION, + config: v2_moderated().into(), + unknown_config_keys: vec![], + }, + CaseSpec { + name: "v2_elected", + platform_version: V2_CONFIG_PROTOCOL_VERSION, + config: v2_elected().into(), + unknown_config_keys: vec![], + }, + ] +} + +/// One minimal document type, valid under every document meta-schema. +fn note_document_schemas() -> BTreeMap { + BTreeMap::from([( + "note".to_string(), + platform_value!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0u32 + } + }, + "additionalProperties": false + }), + )]) +} + +fn platform_version(protocol_version: u32) -> &'static PlatformVersion { + PlatformVersion::get(protocol_version) + .unwrap_or_else(|e| panic!("protocol version {protocol_version} is unknown: {e}")) +} + +/// The contract envelope for a case, in the format the case's protocol +/// version selects as its default. +fn envelope(spec: &CaseSpec) -> DataContractInSerializationFormat { + let format_version = platform_version(spec.platform_version) + .dpp + .contract_versions + .contract_serialization_version + .default_current_version; + match format_version { + 0 => DataContractInSerializationFormatV0 { + id: Identifier::new(CONTRACT_ID), + config: spec.config.clone(), + version: 1, + owner_id: Identifier::new(OWNER_ID), + schema_defs: None, + document_schemas: note_document_schemas(), + } + .into(), + 1 => DataContractInSerializationFormatV1 { + id: Identifier::new(CONTRACT_ID), + config: spec.config.clone(), + version: 1, + owner_id: Identifier::new(OWNER_ID), + schema_defs: None, + document_schemas: note_document_schemas(), + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: Default::default(), + keywords: Default::default(), + description: None, + } + .into(), + version => panic!( + "{}", + mirror_notice(&format!( + "contract serialization format {version} has no generator in this corpus" + )) + ), + } +} + +/// The typed expectation for a decoded configuration, read through the enum +/// getters and keyed by the wire names, so every mirror compares its own +/// re-encoded configuration against it without a translation table. +fn expected_config(config: &DataContractConfig) -> JsonValue { + let mut expect = Map::new(); + expect.insert( + "$formatVersion".to_string(), + json!(config.version().to_string()), + ); + expect.insert("canBeDeleted".to_string(), json!(config.can_be_deleted())); + expect.insert("readonly".to_string(), json!(config.readonly())); + expect.insert("keepsHistory".to_string(), json!(config.keeps_history())); + expect.insert( + "documentsKeepHistoryContractDefault".to_string(), + json!(config.documents_keep_history_contract_default()), + ); + expect.insert( + "documentsMutableContractDefault".to_string(), + json!(config.documents_mutable_contract_default()), + ); + expect.insert( + "documentsCanBeDeletedContractDefault".to_string(), + json!(config.documents_can_be_deleted_contract_default()), + ); + expect.insert( + "requiresIdentityEncryptionBoundedKey".to_string(), + json!(config + .requires_identity_encryption_bounded_key() + .map(|requirement| requirement as u8)), + ); + expect.insert( + "requiresIdentityDecryptionBoundedKey".to_string(), + json!(config + .requires_identity_decryption_bounded_key() + .map(|requirement| requirement as u8)), + ); + if matches!( + config, + DataContractConfig::V1(_) | DataContractConfig::V2(_) + ) { + // The V0 wire carries no `sizedIntegerTypes`; the enum getter reports + // `false` for it, which is a Rust convenience, not a field. + expect.insert( + "sizedIntegerTypes".to_string(), + json!(config.sized_integer_types()), + ); + } + if let Some(moderation) = config.moderation() { + // Only a V2 wire carries `moderation`, and only when the contract + // declares it: an unmoderated V2 configuration has no such key, so a + // mirror must not invent one. The enum getter reports `None` below + // V2, a Rust convenience like `sized_integer_types` above. + expect.insert( + "moderation".to_string(), + serde_json::to_value(moderation).expect("the moderation declaration serializes"), + ); + } + JsonValue::Object(expect) +} + +fn generate_case(spec: CaseSpec) -> Case { + let canonical = serde_json::to_value(envelope(&spec)).expect("envelope serializes"); + let mut contract = canonical.clone(); + let config_block = contract["config"] + .as_object_mut() + .expect("the envelope carries a config object"); + for (key, value) in spec.unknown_config_keys { + config_block.insert(key.to_string(), value); + } + Case { + name: spec.name.to_string(), + platform_version: spec.platform_version, + contract, + canonical, + expect: expected_config(&spec.config), + } +} + +fn generate_corpus() -> Corpus { + Corpus { + description: format!( + "Canonical contract configuration vectors generated by rs-dpp \ + (packages/rs-dpp/src/data_contract/config/vectors). Every mirror decodes \ + `contract` at `platformVersion`, must observe `expect`, and must re-serialize \ + to `canonical`. Regenerate with {WRITE_ENV}=1 cargo test -p dpp \ + data_contract::config::vectors" + ), + cases: specs().into_iter().map(generate_case).collect(), + } +} + +fn pinned_corpus() -> Corpus { + serde_json::from_str(CORPUS).expect("the pinned corpus parses") +} + +/// Renders a contract the way every emitter does: through the serialization +/// format the supplied version selects, then serde. +fn render(contract: &DataContract, platform_version: &PlatformVersion) -> JsonValue { + let format: DataContractInSerializationFormat = contract + .try_into_platform_versioned(platform_version) + .expect("contract converts to its serialization format"); + serde_json::to_value(format).expect("serialization format serializes") +} + +fn keys(value: &JsonValue) -> BTreeSet { + value + .as_object() + .expect("a JSON object") + .keys() + .cloned() + .collect() +} + +fn mirror_notice(what: &str) -> String { + format!( + "{what}\n\ + The contract configuration wire shape is mirrored outside rs-dpp; every mirror \ + must move with it:\n{MIRROR_SURFACES}\n\ + Regenerate the corpus with {WRITE_ENV}=1 cargo test -p dpp \ + data_contract::config::vectors, add a case for the new generation or field, and \ + extend every mirror in the same change (or stack the mirror change on it): the \ + mobile CI jobs replay the corpus." + ) +} + +#[test] +fn should_round_trip_every_vector_through_rs_dpp() { + for case in pinned_corpus().cases { + let platform_version = platform_version(case.platform_version); + let contract = DataContract::from_json(case.contract.clone(), true, platform_version) + .unwrap_or_else(|e| panic!("{}: the vector does not decode: {e}", case.name)); + + let config = contract.config(); + assert_eq!( + expected_config(config), + case.expect, + "{}: the decoded configuration differs from expect", + case.name + ); + assert_eq!( + config.version().to_string(), + case.expect["$formatVersion"], + "{}: the configuration generation differs from the expected tag", + case.name + ); + match case.expect.get("sizedIntegerTypes") { + Some(JsonValue::Bool(expected)) => assert_eq!( + config.sized_integer_types(), + *expected, + "{}: sizedIntegerTypes differs", + case.name + ), + None => assert!( + !config.sized_integer_types(), + "{}: a configuration without sizedIntegerTypes reports it as false", + case.name + ), + Some(other) => panic!("{}: sizedIntegerTypes is not a boolean: {other}", case.name), + } + + assert_eq!( + render(&contract, platform_version), + case.canonical, + "{}: the re-serialized contract differs from canonical", + case.name + ); + assert_eq!( + case.expect, case.canonical["config"], + "{}: expect and the canonical config block disagree", + case.name + ); + } +} + +#[test] +fn should_keep_the_corpus_equal_to_the_generators_output() { + let mut generated = + serde_json::to_string_pretty(&generate_corpus()).expect("the corpus serializes"); + generated.push('\n'); + + if std::env::var_os(WRITE_ENV).is_some() { + std::fs::write(CORPUS_PATH, &generated).expect("the corpus is written"); + return; + } + + assert!( + CORPUS == generated, + "{}", + mirror_notice( + "the pinned corpus differs from the generator's output; regenerate it and review \ + the diff" + ) + ); +} + +#[test] +fn should_drop_unknown_config_keys_when_re_serializing() { + let corpus = pinned_corpus(); + let case = corpus + .cases + .iter() + .find(|case| keys(&case.contract["config"]) != keys(&case.canonical["config"])) + .expect("the corpus carries a case whose contract config has a key canonical lacks"); + + let extra: BTreeSet = keys(&case.contract["config"]) + .difference(&keys(&case.canonical["config"])) + .cloned() + .collect(); + assert!(!extra.is_empty(), "{}: no extra key", case.name); + for key in &extra { + assert!( + case.expect.get(key).is_none(), + "{}: the unknown key {key} must not be expected of a mirror", + case.name + ); + } +} + +#[test] +fn should_cover_every_default_config_generation_with_a_vector() { + let corpus = pinned_corpus(); + + // (tag, key set) of the configuration each protocol version creates by + // default, derived from the types, with the versions that select it. + let mut generations: BTreeMap<(String, BTreeSet), Vec> = BTreeMap::new(); + for protocol_version in ALL_VERSIONS { + let config = DataContractConfig::default_for_version(platform_version(protocol_version)) + .unwrap_or_else(|e| panic!("protocol version {protocol_version}: {e}")); + let wire = serde_json::to_value(config).expect("the configuration serializes"); + let tag = wire["$formatVersion"] + .as_str() + .expect("the configuration carries a format tag") + .to_string(); + generations + .entry((tag, keys(&wire))) + .or_default() + .push(protocol_version); + } + + for ((tag, wire_keys), protocol_versions) in generations { + let covered = corpus.cases.iter().any(|case| { + let default_tag = + DataContractConfig::default_for_version(platform_version(case.platform_version)) + .expect("the case's version has a default configuration") + .version() + .to_string(); + default_tag == tag + && case.expect["$formatVersion"] == tag + && keys(&case.expect) == wire_keys + }); + assert!( + covered, + "{}", + mirror_notice(&format!( + "protocol versions {protocol_versions:?} create a configuration tagged \ + \"{tag}\" with keys {wire_keys:?} by default, and no vector generated at \ + one of those versions expects exactly that key set" + )) + ); + } +} + +#[test] +fn should_pin_the_v0_v1_and_v2_config_key_sets() { + let v0: BTreeSet = [ + "$formatVersion", + "canBeDeleted", + "readonly", + "keepsHistory", + "documentsKeepHistoryContractDefault", + "documentsMutableContractDefault", + "documentsCanBeDeletedContractDefault", + "requiresIdentityEncryptionBoundedKey", + "requiresIdentityDecryptionBoundedKey", + ] + .into_iter() + .map(str::to_string) + .collect(); + let mut v1 = v0.clone(); + v1.insert("sizedIntegerTypes".to_string()); + + let v0_wire = serde_json::to_value(DataContractConfig::V0(DataContractConfigV0::default())) + .expect("a V0 configuration serializes"); + assert!( + keys(&v0_wire) == v0, + "{}", + mirror_notice(&format!( + "the V0 configuration keys changed to {:?}", + keys(&v0_wire) + )) + ); + + let v1_wire = serde_json::to_value(DataContractConfig::V1(DataContractConfigV1::default())) + .expect("a V1 configuration serializes"); + assert!( + keys(&v1_wire) == v1, + "{}", + mirror_notice(&format!( + "the V1 configuration keys changed to {:?}", + keys(&v1_wire) + )) + ); + + // An unmoderated V2 configuration carries the V1 keys under the "2" + // tag; the `moderation` key appears only when the contract declares it. + let v2_wire = serde_json::to_value(DataContractConfig::V2(DataContractConfigV2::default())) + .expect("a V2 configuration serializes"); + assert!( + keys(&v2_wire) == v1, + "{}", + mirror_notice(&format!( + "the unmoderated V2 configuration keys changed to {:?}", + keys(&v2_wire) + )) + ); + let mut v2_moderated_keys = v1.clone(); + v2_moderated_keys.insert("moderation".to_string()); + let v2_moderated_wire = serde_json::to_value(DataContractConfig::V2(v2_moderated())) + .expect("a moderated V2 configuration serializes"); + assert!( + keys(&v2_moderated_wire) == v2_moderated_keys, + "{}", + mirror_notice(&format!( + "the moderated V2 configuration keys changed to {:?}", + keys(&v2_moderated_wire) + )) + ); + assert_eq!( + keys(&v2_moderated_wire["moderation"]), + ["banlist", "suspensions", "warnings", "moderators"] + .into_iter() + .map(str::to_string) + .collect::>(), + "{}", + mirror_notice("the moderation declaration keys changed") + ); + + // The moderators kinds are tagged maps; the elected declaration carries + // every key of its kind (the election delay only when declared). + let owner_wire = + serde_json::to_value(ContractModerators::ContractOwner).expect("the owner kind serializes"); + assert_eq!(owner_wire, json!({ "$type": "contractOwner" })); + let appointed_wire = + serde_json::to_value(ContractModerators::AppointedModerators(BTreeSet::from([ + Identifier::new(OWNER_ID), + ]))) + .expect("the appointed kind serializes"); + assert_eq!( + keys(&appointed_wire), + ["$type", "identities"] + .into_iter() + .map(str::to_string) + .collect::>() + ); + assert_eq!(appointed_wire["$type"], "appointedModerators"); + let elected_wire = serde_json::to_value(DataContractConfig::V2(v2_elected())) + .expect("an elected configuration serializes")["moderation"]["moderators"] + .clone(); + assert_eq!(elected_wire["$type"], "elected"); + assert_eq!( + keys(&elected_wire), + [ + "$type", + "joinWindow", + "voteWindow", + "seatContestable", + "challengeCoolDown", + "electionDelay", + "maxAddedModerators", + "moderatedDocumentTypes", + "interim", + "ownerProtected", + ] + .into_iter() + .map(str::to_string) + .collect::>(), + "{}", + mirror_notice("the elected moderators declaration keys changed") + ); +} diff --git a/packages/rs-sdk-ffi/src/data_contract/json.rs b/packages/rs-sdk-ffi/src/data_contract/json.rs new file mode 100644 index 00000000000..447a15768c8 --- /dev/null +++ b/packages/rs-sdk-ffi/src/data_contract/json.rs @@ -0,0 +1,142 @@ +//! The one JSON rendering of a data contract the FFI hands to Swift and +//! Kotlin. +//! +//! Every emitter (`fetch_json`, `fetch_many`, `fetch_by_range`, +//! `fetch_with_serialization`) renders through [`contract_json_value`] at +//! the SDK's network protocol version, so the JSON a mobile host persists +//! is the same envelope for every query and matches the proof-verified +//! fetch. The canonical `serde_json::to_value(&DataContract)` path instead +//! reads the process-global current platform version, which the SDK never +//! sets, so it would silently resolve to `PlatformVersion::latest()` and +//! diverge from the network the moment a newer serialization generation +//! exists. + +use crate::error::{DashSDKError, DashSDKErrorCode}; +use dash_sdk::dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dash_sdk::dpp::version::{PlatformVersion, TryIntoPlatformVersioned}; +use dash_sdk::platform::DataContract; + +/// The contract in its serialization format at `platform_version`, as JSON. +/// +/// The envelope generation (`$formatVersion`) is chosen from the supplied +/// version's tables, and the nested `config` block keeps the generation the +/// contract carries; a contract registered with a V0 configuration (the +/// default through protocol version 8, refused from 10) still renders it +/// inside a V1 envelope. +pub(crate) fn contract_json_value( + contract: &DataContract, + platform_version: &PlatformVersion, +) -> Result { + let format: DataContractInSerializationFormat = contract + .try_into_platform_versioned(platform_version) + .map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!( + "Failed to convert contract to its serialization format: {}", + e + ), + ) + })?; + + serde_json::to_value(&format).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!("Failed to convert contract to JSON: {}", e), + ) + }) +} + +#[cfg(test)] +mod tests { + //! Replays the canonical contract configuration corpus pinned by rs-dpp + //! (`packages/rs-dpp/src/data_contract/config/vectors`): the JSON this + //! crate emits must be the canonical envelope at the supplied version. + + use super::*; + use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; + use serde::Deserialize; + + const CORPUS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json" + )); + + /// A protocol version whose tables select the V1 contract envelope. + const V1_ENVELOPE_PROTOCOL_VERSION: u32 = 14; + + #[derive(Deserialize)] + struct Corpus { + cases: Vec, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Case { + name: String, + platform_version: u32, + contract: serde_json::Value, + canonical: serde_json::Value, + } + + fn corpus() -> Corpus { + serde_json::from_str(CORPUS).expect("the pinned corpus parses") + } + + fn platform_version(protocol_version: u32) -> &'static PlatformVersion { + PlatformVersion::get(protocol_version) + .unwrap_or_else(|e| panic!("protocol version {protocol_version} is unknown: {e}")) + } + + fn decode(case: &Case) -> DataContract { + DataContract::from_json( + case.contract.clone(), + true, + platform_version(case.platform_version), + ) + .unwrap_or_else(|e| panic!("{}: the vector does not decode: {e}", case.name)) + } + + /// `DashSDKError` carries a raw C string and has no `Debug`; unwrap by + /// the error code instead. + fn render(contract: &DataContract, protocol_version: u32) -> serde_json::Value { + contract_json_value(contract, platform_version(protocol_version)) + .unwrap_or_else(|e| panic!("rendering failed with code {:?}", e.code)) + } + + #[test] + fn should_render_every_vector_as_the_canonical_envelope() { + for case in corpus().cases { + let contract = decode(&case); + assert_eq!( + render(&contract, case.platform_version), + case.canonical, + "{}: the FFI JSON differs from the canonical envelope", + case.name + ); + } + } + + #[test] + fn should_render_the_supplied_version_not_the_global_one() { + let corpus = corpus(); + let case = corpus + .cases + .iter() + .find(|case| case.name == "v0_envelope") + .expect("the corpus carries the historic V0 envelope case"); + let contract = decode(case); + + let historic = render(&contract, case.platform_version); + assert_eq!(historic["$formatVersion"], "0"); + assert_eq!(historic["config"]["$formatVersion"], "0"); + + // Rendered at a version whose tables select the V1 envelope, the + // same contract comes back in the current envelope with its + // configuration generation untouched. The argument alone decides. + let current = render(&contract, V1_ENVELOPE_PROTOCOL_VERSION); + assert_eq!(current["$formatVersion"], "1"); + assert_eq!(current["config"]["$formatVersion"], "0"); + assert_eq!(current["config"], historic["config"]); + } +} diff --git a/packages/rs-sdk-ffi/src/data_contract/mod.rs b/packages/rs-sdk-ffi/src/data_contract/mod.rs index 2cd73e05d98..c3f947fa303 100644 --- a/packages/rs-sdk-ffi/src/data_contract/mod.rs +++ b/packages/rs-sdk-ffi/src/data_contract/mod.rs @@ -20,6 +20,7 @@ //! - The `propertyConstraints` rules of a document type, and the //! pre-check of a document against them (`property_constraints`). +mod json; mod property_constraints; mod put; mod queries; diff --git a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_by_range.rs b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_by_range.rs index b56cd3e2ee0..13a8a525097 100644 --- a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_by_range.rs +++ b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_by_range.rs @@ -13,6 +13,7 @@ use serde_json::{Map, Value}; use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use crate::data_contract::json::contract_json_value; use crate::error::{DashSDKError, DashSDKErrorCode, FFIError}; use crate::runtime::BigStackRuntime; use crate::sdk::SDKWrapper; @@ -145,17 +146,13 @@ unsafe fn fetch_data_contracts_by_range( // empty so an exhausted enumeration reads as `[]` rather than an error. let page = page?.unwrap_or_default(); + // Render every contract at the SDK's network protocol version, the same + // way every other contract emitter does. + let platform_version = wrapper.sdk.version(); let mut entries = Vec::with_capacity(page.0.len()); for (id, contract) in page.0.iter() { - // The outer DataContract enum has a manual Serialize impl that threads the - // active platform version, so canonical serde is the right JSON here. let contract_json = match contract { - Some(contract) => serde_json::to_value(contract).map_err(|e| { - DashSDKError::new( - DashSDKErrorCode::SerializationError, - format!("Failed to convert contract to JSON: {}", e), - ) - })?, + Some(contract) => contract_json_value(contract, platform_version)?, None => Value::Null, }; diff --git a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs index 17a1fe67587..200af6420b9 100644 --- a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs +++ b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_json.rs @@ -1,3 +1,4 @@ +use crate::data_contract::json::contract_json_value; use crate::error::{DashSDKError, DashSDKErrorCode, FFIError}; use crate::sdk::SDKWrapper; use crate::types::{DashSDKResult, SDKHandle}; @@ -49,9 +50,9 @@ pub unsafe extern "C" fn dash_sdk_data_contract_fetch_json( match result { Ok(Some(contract)) => { - // Convert to JSON via canonical serde (manual Serialize on the - // outer DataContract enum threads the active platform version). - match serde_json::to_value(&contract) { + // Render at the SDK's network protocol version, the same way + // every other contract emitter does. + match contract_json_value(&contract, wrapper.sdk.version()) { Ok(json_value) => match serde_json::to_string(&json_value) { Ok(json_string) => match CString::new(json_string) { Ok(c_str) => DashSDKResult::success_string(c_str.into_raw()), @@ -59,10 +60,7 @@ pub unsafe extern "C" fn dash_sdk_data_contract_fetch_json( }, Err(e) => DashSDKResult::error(FFIError::from(e).into()), }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::SerializationError, - format!("Failed to convert contract to JSON: {}", e), - )), + Err(e) => DashSDKResult::error(e), } } Ok(None) => DashSDKResult::error(DashSDKError::new( diff --git a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_many.rs b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_many.rs index 44fe4781bf3..9ed7913be92 100644 --- a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_many.rs +++ b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_many.rs @@ -4,9 +4,11 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; use dash_sdk::platform::{DataContract, FetchMany}; use dash_sdk::query_types::DataContracts; +use serde_json::{Map, Value}; use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use crate::data_contract::json::contract_json_value; use crate::sdk::SDKWrapper; use crate::types::SDKHandle; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; @@ -84,29 +86,30 @@ pub unsafe extern "C" fn dash_sdk_data_contracts_fetch_many( Err(e) => return DashSDKResult::error(e), }; - let result: Result = wrapper.runtime.block_on(async { + let result: Result = wrapper.runtime.block_on(async { // Fetch data contracts let contracts: DataContracts = DataContract::fetch_many(&wrapper.sdk, identifiers) .await - .map_err(FFIError::from)?; + .map_err(|e| DashSDKError::from(FFIError::from(e)))?; - // Convert to JSON string - let mut json_parts = Vec::new(); + // Render every contract at the SDK's network protocol version, the + // same way every other contract emitter does. + let platform_version = wrapper.sdk.version(); + let mut entries = Map::new(); for (id, contract_opt) in contracts { let contract_json = match contract_opt { - Some(contract) => { - serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string()) - } - None => "null".to_string(), + Some(contract) => contract_json_value(&contract, platform_version)?, + None => Value::Null, }; - json_parts.push(format!( - "\"{}\":{}", - id.to_string(Encoding::Base58), - contract_json - )); + entries.insert(id.to_string(Encoding::Base58), contract_json); } - Ok(format!("{{{}}}", json_parts.join(","))) + serde_json::to_string(&Value::Object(entries)).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!("Failed to serialize contracts: {}", e), + ) + }) }); match result { @@ -121,6 +124,6 @@ pub unsafe extern "C" fn dash_sdk_data_contracts_fetch_many( }; DashSDKResult::success_string(c_str.into_raw()) } - Err(e) => DashSDKResult::error(e.into()), + Err(e) => DashSDKResult::error(e), } } diff --git a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs index b9c51ae96bd..f530d8a50c9 100644 --- a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs +++ b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs @@ -1,11 +1,10 @@ +use crate::data_contract::json::contract_json_value; use crate::sdk::SDKWrapper; use crate::{ dash_sdk_error_free, DashSDKError, DashSDKErrorCode, DataContractHandle, FFIError, SDKHandle, }; -use dash_sdk::dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dash_sdk::dpp::data_contract::DataContractWithSerialization; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::version::TryIntoPlatformVersioned; use dash_sdk::platform::{Fetch, Identifier}; use std::ffi::{CStr, CString}; use std::os::raw::c_char; @@ -110,27 +109,10 @@ pub unsafe extern "C" fn dash_sdk_data_contract_fetch_with_serialization( // Always create a handle since we have the contract let handle = Some(Box::into_raw(Box::new(contract.clone())) as *mut DataContractHandle); - // Prepare JSON if requested + // Prepare JSON if requested, rendered at the SDK's network + // protocol version so it matches the proof-verified fetch. let json = if return_json { - // Serialize at the SDK's network protocol version (not the - // process-global current/latest) so the JSON matches the - // proof-verified fetch. Mirrors DataContract's own serde with - // the version pinned to `wrapper.sdk.version()`. - let platform_version = wrapper.sdk.version(); - let format: DataContractInSerializationFormat = - match contract.try_into_platform_versioned(platform_version) { - Ok(format) => format, - Err(e) => { - return DashSDKDataContractFetchResult::error(DashSDKError::new( - DashSDKErrorCode::SerializationError, - format!( - "Failed to convert contract to its serialization format: {}", - e - ), - )) - } - }; - match serde_json::to_value(&format) { + match contract_json_value(&contract, wrapper.sdk.version()) { Ok(json_value) => match serde_json::to_string(&json_value) { Ok(json_string) => match CString::new(json_string) { Ok(c_str) => Some(c_str.into_raw()), @@ -144,12 +126,7 @@ pub unsafe extern "C" fn dash_sdk_data_contract_fetch_with_serialization( return DashSDKDataContractFetchResult::error(FFIError::from(e).into()) } }, - Err(e) => { - return DashSDKDataContractFetchResult::error(DashSDKError::new( - DashSDKErrorCode::SerializationError, - format!("Failed to convert contract to JSON: {}", e), - )) - } + Err(e) => return DashSDKDataContractFetchResult::error(e), } } else { None diff --git a/packages/wasm-dpp2/package.json b/packages/wasm-dpp2/package.json index 5d7dcf94113..d6a77d7dd46 100644 --- a/packages/wasm-dpp2/package.json +++ b/packages/wasm-dpp2/package.json @@ -30,9 +30,10 @@ "scripts": { "build": "./scripts/build.sh && node ./scripts/bundle.cjs", "build:release": "./scripts/build-optimized.sh && node ./scripts/bundle.cjs", - "test": "yarn run test:unit", + "test": "yarn run test:unit && yarn run test:types", "test:unit": "mocha tests/unit/**/*.spec.ts && karma start ./tests/karma/karma.conf.cjs --single-run", - "lint": "eslint tests/**/*.ts" + "test:types": "tsc -p test-d/tsconfig.json", + "lint": "eslint tests/**/*.ts test-d/**/*.ts" }, "ultra": { "concurrent": [ diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 131f0127ce2..5402eb7571d 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -129,22 +129,40 @@ export interface DataContractJSON { } /** - * DataContract configuration. + * The flags every contract configuration generation carries. The key + * requirements are `StorageKeyRequirements` values (0 unique, 1 multiple, + * 2 multiple reference to latest); an absent requirement is `undefined` in + * object form (the `config` getter) and `null` in JSON form (`toJSON`). */ -export interface DataContractConfig { +export interface DataContractConfigFlags { canBeDeleted: boolean; readonly: boolean; keepsHistory: boolean; documentsKeepHistoryContractDefault: boolean; documentsMutableContractDefault: boolean; documentsCanBeDeletedContractDefault: boolean; - requiresIdentityEncryptionBoundedKey?: number; - requiresIdentityDecryptionBoundedKey?: number; - /** - * Contract moderation (protocol version 14): the banlist, suspension list and/or warning - * list the contract keeps and who may edit them. Absent for an unmoderated contract. - */ - moderation?: ContractModerationConfig; + requiresIdentityEncryptionBoundedKey?: number | null; + requiresIdentityDecryptionBoundedKey?: number | null; +} + +/** + * DataContract configuration, format version 0: the default generation + * through protocol version 8, admitted for new and updated contracts until + * protocol version 9 and refused from 10. Mirrors the rs-dpp + * `DataContractConfig::V0` wire shape one to one. + */ +export interface DataContractConfigV0 extends DataContractConfigFlags { + $formatVersion: '0'; +} + +/** + * DataContract configuration, format version 1: the default generation from + * protocol version 9 through 13, still admitted after V2 becomes the default + * at 14. Adds `sizedIntegerTypes` to the V0 fields. + */ +export interface DataContractConfigV1 extends DataContractConfigFlags { + $formatVersion: '1'; + sizedIntegerTypes: boolean; } /** @@ -226,6 +244,45 @@ export interface ContractModerationConfig { warnings: boolean; moderators: ContractModerators; } + +/** + * DataContract configuration, format version 2: the default generation + * from protocol version 14, moderated or not. + * Adds the optional `moderation` declaration to the V1 fields; it is absent + * for an unmoderated contract. + */ +export interface DataContractConfigV2 extends DataContractConfigFlags { + $formatVersion: '2'; + sizedIntegerTypes: boolean; + /** + * Contract moderation (protocol version 14): the banlist, suspension list and/or warning + * list the contract keeps and who may edit them. Absent for an unmoderated contract. + */ + moderation?: ContractModerationConfig; +} + +/** + * DataContract configuration as the runtime exposes it: the tagged union of + * the shipped generations. Contracts already in state may carry either tag. + */ +export type DataContractConfig = + | DataContractConfigV0 + | DataContractConfigV1 + | DataContractConfigV2; + +/** + * The configuration `setConfig` accepts: a tagged configuration as the + * `config` getter returns it, or the bare flags. The generation is selected + * from the platform version passed alongside, so `$formatVersion` in the + * input is ignored; `sizedIntegerTypes` is dropped when that version selects + * format version 0 and defaults to `true` when it selects format version 1 + * or 2; `moderation` is refused when that version selects a format below 2. + */ +export interface DataContractConfigLike extends DataContractConfigFlags { + $formatVersion?: string; + sizedIntegerTypes?: boolean; + moderation?: ContractModerationConfig; +} "#; #[wasm_bindgen] @@ -242,6 +299,9 @@ extern "C" { #[wasm_bindgen(typescript_type = "DataContractConfig")] pub type DataContractConfigJs; + #[wasm_bindgen(typescript_type = "DataContractConfigLike")] + pub type DataContractConfigLikeJs; + #[wasm_bindgen(typescript_type = "Record")] pub type DataContractSchemasJs; @@ -607,7 +667,7 @@ impl DataContractWasm { #[wasm_bindgen(js_name = "setConfig")] pub fn set_config( &mut self, - config: DataContractConfigJs, + config: DataContractConfigLikeJs, #[wasm_bindgen(js_name = "platformVersion")] platform_version: PlatformVersionLikeJs, ) -> WasmDppResult<()> { let config: JsValue = config.into(); diff --git a/packages/wasm-dpp2/test-d/index.test-d.ts b/packages/wasm-dpp2/test-d/index.test-d.ts new file mode 100644 index 00000000000..7160ec4c9fa --- /dev/null +++ b/packages/wasm-dpp2/test-d/index.test-d.ts @@ -0,0 +1,192 @@ +// Typings test for the contract configuration surface. `yarn test:types` +// compiles this file against `dist/dpp.d.ts` and never runs it: a line +// that stops compiling, or a `@ts-expect-error` line that starts +// compiling, fails the check. +import type { + ContractModerationConfig, + ContractModerators, + InterimModerators, + ModerationAbility, + DataContract, + DataContractConfig, + DataContractConfigLike, + DataContractConfigV0, + DataContractConfigV1, + DataContractConfigV2, +} from '@dashevo/wasm-dpp2'; +// The canonical corpus rs-dpp generates and pins; its `expect` blocks carry +// every configuration key a mirror must model. +import vectors from '../../rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json' with { type: 'json' }; + +declare const dataContract: DataContract; + +type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Check = T; + +// The declarations mirror the corpus key sets exactly. `expect` is written in +// JSON form: `sizedIntegerTypes` is absent from the V0 cases and `moderation` +// from every case but the moderated V2 one, so those are the only optional +// corpus keys, and an absent key requirement is `null` where object form +// carries `undefined`. A key added to the corpus without a declaration, or a +// declaration key the corpus never carries, fails here. +type CorpusExpect = (typeof vectors)['cases'][number]['expect']; +type CorpusKeys = Exclude; +// Every key required, `undefined` (the object-form spelling of an absent +// requirement) removed, so the declared value types are compared with the +// JSON-form types the corpus carries, `null` included. +type Shape = { [K in keyof T]-?: Exclude }; +type CorpusShape = Shape>; +// The moderation declaration is compared by key set below: each corpus case +// carries one concrete moderators `$type`, the declaration the union of all. +type Flat = Omit; +type V2Shape = Shape>; +type V1Shape = Shape>; +type V0Shape = Shape>; +type FlagsShape = Shape>; +// The JSON import widens every `$formatVersion` to `string`, so the corpus +// cannot tell the compiler which generation owns a key. The `Omit` lists +// below are where that ownership is stated: a key added to the corpus for V2 +// alone fails the V1 and V0 checks until it is listed here, on purpose. The +// exact per-generation key lists are pinned in rs-dpp by +// `should_pin_the_v0_v1_and_v2_config_key_sets`. +export type V2MirrorsCorpus = Check>>; +export type V1MirrorsCorpus = Check>>; +export type V0MirrorsCorpus = Check>>; +export type SetterInputMirrorsCorpus = Check>>; +export type TagMirrorsCorpus = Check; + +// `moderation` is declared on V2 and on the setter input, optional on both +// (an unmoderated contract has no such key), on no other generation, and its +// keys and the discriminator of `moderators` mirror the corpus. +type CorpusModeration = NonNullable; +export type ModerationOnV2 = Check>>>; +export type ModerationOnSetterInput = Check>>>; +export type ModerationOptionalOnV2 = Check; +export type ModerationOptionalOnSetterInput = Check; +export type ModerationNotOnV1 = Check<'moderation' extends keyof DataContractConfigV1 ? false : true>; +export type ModerationNotOnV0 = Check<'moderation' extends keyof DataContractConfigV0 ? false : true>; +export type ModerationMirrorsCorpus = Check>; +export type ModerationListsAreFlags = Check, + Pick +>>; +// The corpus carries the owner kind and the elected kind, and its `$type` +// discriminators are widened to plain strings by the JSON import, so the +// kinds are compared by key set: every key the corpus moderators carry is +// declared, and every declared key but the appointed set's `identities` +// (whose kind the corpus does not carry, an identifier rendering differently +// in object form and in JSON form) is carried by the corpus. +type KeysOfUnion = T extends unknown ? keyof T : never; +export type ModeratorsMirrorCorpus = Check, 'identities'>, KeysOfUnion>>; +type CorpusElected = Extract; +type DeclaredElected = Extract; +export type ElectedMirrorsCorpus = Check, keyof CorpusElected>>; +export type ElectedWindowsAreSeconds = Check, 'joinWindow' | 'voteWindow' | 'challengeCoolDown' | 'electionDelay' | 'maxAddedModerators' | 'ownerProtected'>, + Pick +>>; +export type InterimMirrorsCorpus = Check, 'identities'>, keyof CorpusElected['interim']>>; +export type AbilitiesAreNames = Check; +export type DeclaredAbilitiesAreNames = Check; + +function expectAssignable(value: T): T { + return value; +} + +// The six flags without a format tag: the shape `setConfig` accepted before +// the configuration type carried `$formatVersion`. It must keep compiling +// without a cast. +const flags = { + canBeDeleted: false, + readonly: false, + keepsHistory: false, + documentsKeepHistoryContractDefault: false, + documentsMutableContractDefault: true, + documentsCanBeDeletedContractDefault: true, +}; +dataContract.setConfig(flags, 1); +dataContract.setConfig({ ...flags, requiresIdentityEncryptionBoundedKey: 0 }, 1); +dataContract.setConfig({ ...flags, requiresIdentityDecryptionBoundedKey: null }, 1); +dataContract.setConfig({ ...flags, sizedIntegerTypes: false }, 1); +expectAssignable(flags); + +// The getter returns the tagged union, and its output feeds `setConfig` +// back without a cast, edited or not. +const config: DataContractConfig = dataContract.config; +dataContract.setConfig(config, 1); +dataContract.setConfig({ ...config, canBeDeleted: !config.canBeDeleted }, 1); + +// Narrowing on the tag exposes `sizedIntegerTypes` on format versions 1 and +// 2 only, and `moderation` on format version 2 only. +if (config.$formatVersion === '1') { + expectAssignable(config.sizedIntegerTypes); + // @ts-expect-error moderation is not a V1 field + expectAssignable(config.moderation); +} +if (config.$formatVersion === '2') { + expectAssignable(config.sizedIntegerTypes); + expectAssignable(config.moderation); +} +if (config.$formatVersion === '0') { + // @ts-expect-error sizedIntegerTypes is not a V0 field + expectAssignable(config.sizedIntegerTypes); +} + +// A moderation declaration rides along on the setter input, as the bare +// flags or on top of the getter's output. +const moderation: ContractModerationConfig = { + banlist: true, + suspensions: false, + warnings: false, + moderators: { $type: 'contractOwner' }, +}; +dataContract.setConfig({ ...flags, moderation }, 14); +dataContract.setConfig({ ...config, moderation }, 14); +dataContract.setConfig({ + ...flags, + moderation: { ...moderation, moderators: { $type: 'appointedModerators', identities: ['11111111111111111111111111111111'] } }, +}, 14); +// @ts-expect-error appointed moderators name their identities +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: { $type: 'appointedModerators' } } }, 14); +// @ts-expect-error every list flag is declared +dataContract.setConfig({ ...flags, moderation: { banlist: true, suspensions: false, moderators: { $type: 'contractOwner' } } }, 14); + +// An elected declaration: whether the seat is contestable (with its cool-down +// when it is), the moderated types and the interim moderators are required, +// the windows, the delay, the added members and the flag optional. +const elected: ContractModerators = { + $type: 'elected', + seatContestable: true, + challengeCoolDown: 1209600, + moderatedDocumentTypes: { note: ['ban', 'warn'] }, + interim: { $type: 'notYetUsable' }, +}; +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: elected } }, 14); +dataContract.setConfig({ + ...flags, + moderation: { + ...moderation, + moderators: { + ...elected, joinWindow: 86400, voteWindow: 172800, electionDelay: 3600, maxAddedModerators: 2, ownerProtected: true, + }, + }, +}, 14); +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: { $type: 'elected', seatContestable: false, moderatedDocumentTypes: { note: ['ban'] }, interim: { $type: 'noModeration' } } } }, 14); +// @ts-expect-error whether the seat is contestable has no default +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: { $type: 'elected', moderatedDocumentTypes: {}, interim: { $type: 'noModeration' } } } }, 14); +// @ts-expect-error a contestable seat declares its cool-down +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: { $type: 'elected', seatContestable: true, moderatedDocumentTypes: {}, interim: { $type: 'noModeration' } } } }, 14); +// @ts-expect-error a seat that can not be contested has no cool-down +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: { ...elected, seatContestable: false } } }, 14); +// @ts-expect-error an ability is one of the five +dataContract.setConfig({ ...flags, moderation: { ...moderation, moderators: { ...elected, moderatedDocumentTypes: { note: ['delete'] } } } }, 14); + +// A tag read back from JSON is a plain string; `setConfig` still accepts it. +const fromJson: { $formatVersion: string } & typeof flags = { $formatVersion: '1', ...flags }; +dataContract.setConfig(fromJson, 1); + +// The flags stay required and typed. +// @ts-expect-error the other five flags are missing +dataContract.setConfig({ canBeDeleted: true }, 1); +// @ts-expect-error a flag must be a boolean +dataContract.setConfig({ ...flags, canBeDeleted: 'yes' }, 1); diff --git a/packages/wasm-dpp2/test-d/tsconfig.json b/packages/wasm-dpp2/test-d/tsconfig.json new file mode 100644 index 00000000000..887aebc72af --- /dev/null +++ b/packages/wasm-dpp2/test-d/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "types": [], + "baseUrl": ".", + "paths": { + "@dashevo/wasm-dpp2": ["../dist/dpp.d.ts"] + } + }, + "include": ["**/*.ts"] +} diff --git a/packages/wasm-dpp2/tests/unit/DataContractConfigVectors.spec.ts b/packages/wasm-dpp2/tests/unit/DataContractConfigVectors.spec.ts new file mode 100644 index 00000000000..ed0c11b400e --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DataContractConfigVectors.spec.ts @@ -0,0 +1,144 @@ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; +// The canonical corpus rs-dpp generates and pins; every client mirror of +// the contract configuration replays it. See +// packages/rs-dpp/src/data_contract/config/vectors/mod.rs. +import vectors from '../../../rs-dpp/src/data_contract/config/vectors/contract_config_vectors.json' with { type: 'json' }; + +interface VectorCase { + name: string; + platformVersion: number; + contract: Record; + canonical: Record; + expect: Record; +} + +const cases = vectors.cases as VectorCase[]; + +/** + * `expect` is written in JSON form, where an absent key requirement is + * `null`. The `config` getter returns object form, where it is `undefined`. + */ +function toObjectForm(config: Record): Record { + return Object.fromEntries( + Object.entries(config).map(([key, value]) => [key, value === null ? undefined : value]), + ); +} + +before(async () => { + await initWasm(); +}); + +describe('DataContract configuration vectors', () => { + it('should carry every case the mirror suite expects', () => { + expect(cases.map((c) => c.name)).to.deep.equal([ + 'v1_defaults', + 'v1_all_set', + 'v0_defaults_in_v1_envelope', + 'v0_key_requirements', + 'v0_envelope', + 'v1_unknown_field_is_ignored', + 'v2_defaults', + 'v2_moderated', + 'v2_elected', + ]); + }); + + cases.forEach((vector) => { + describe(vector.name, () => { + it('should expose the expected configuration through the config getter', () => { + const platformVersion = new wasm.PlatformVersion(vector.platformVersion); + const dataContract = wasm.DataContract.fromJSON(vector.contract, true, platformVersion); + + expect(dataContract.config).to.deep.equal(toObjectForm(vector.expect)); + }); + + it('should render the canonical config block through toJSON', () => { + const platformVersion = new wasm.PlatformVersion(vector.platformVersion); + const dataContract = wasm.DataContract.fromJSON(vector.contract, true, platformVersion); + + const json = dataContract.toJSON(platformVersion); + + expect(json.$formatVersion).to.equal(vector.canonical.$formatVersion); + expect(json.config).to.deep.equal(vector.canonical.config); + }); + + if (vector.expect.$formatVersion !== '0') { + it('should round-trip the expected configuration through setConfig', () => { + const platformVersion = new wasm.PlatformVersion(vector.platformVersion); + const dataContract = wasm.DataContract.fromJSON(vector.contract, true, platformVersion); + + // The getter's output feeds setConfig back untouched; no cast. + const config = toObjectForm(vector.expect); + dataContract.setConfig(config, platformVersion); + + expect(dataContract.config).to.deep.equal(config); + }); + } + }); + }); + + // The bare flags select the generation from the platform version: format + // version 1 at the V1 case's version, 2 at the V2 case's, with + // `sizedIntegerTypes` defaulted and no `moderation` key invented. + [['v1_all_set', '1'], ['v2_moderated', '2']].forEach(([name, formatVersion]) => { + it(`should accept the bare flags without a format tag and select format version ${formatVersion}`, () => { + const vector = cases.find((c) => c.name === name) as VectorCase; + const platformVersion = new wasm.PlatformVersion(vector.platformVersion); + const dataContract = wasm.DataContract.fromJSON(vector.contract, true, platformVersion); + + const flags = Object.fromEntries( + Object.entries(vector.expect).filter( + ([key]) => key !== '$formatVersion' && key !== 'sizedIntegerTypes' && key !== 'moderation', + ), + ); + dataContract.setConfig(toObjectForm(flags), platformVersion); + + expect(dataContract.config).to.deep.equal({ + ...toObjectForm(flags), + $formatVersion: formatVersion, + sizedIntegerTypes: true, + }); + }); + }); + + it('should refuse a moderation declaration at a version whose configuration cannot carry it', () => { + const vector = cases.find((c) => c.name === 'v2_moderated') as VectorCase; + const v1Vector = cases.find((c) => c.name === 'v1_all_set') as VectorCase; + expect(vector.expect).to.have.property('moderation'); + + // The V1 case's version selects a configuration generation without the + // `moderation` key. Dropping the declaration would leave the contract + // unmoderated for good, so both the setter and the renderer refuse it. + const platformVersion = new wasm.PlatformVersion(v1Vector.platformVersion); + const dataContract = wasm.DataContract.fromJSON(v1Vector.contract, true, platformVersion); + expect(() => dataContract.setConfig(toObjectForm(vector.expect), platformVersion)).to.throw(); + expect(dataContract.config).to.deep.equal(toObjectForm(v1Vector.expect)); + + const moderated = wasm.DataContract.fromJSON( + vector.contract, + true, + new wasm.PlatformVersion(vector.platformVersion), + ); + expect(() => moderated.toJSON(platformVersion)).to.throw(); + }); + + it('should ignore unknown configuration keys and drop them when re-serializing', () => { + const vector = cases.find((c) => c.name === 'v1_unknown_field_is_ignored'); + expect(vector).to.exist(); + const { contract, canonical, platformVersion: version } = vector as VectorCase; + const contractConfig = contract.config as Record; + const canonicalConfig = canonical.config as Record; + const unknownKeys = Object.keys(contractConfig).filter((key) => !(key in canonicalConfig)); + expect(unknownKeys).to.not.be.empty(); + + const platformVersion = new wasm.PlatformVersion(version); + const dataContract = wasm.DataContract.fromJSON(contract, true, platformVersion); + + const config = dataContract.config as Record; + unknownKeys.forEach((key) => { + expect(config).to.not.have.property(key); + }); + expect(dataContract.toJSON(platformVersion).config).to.deep.equal(canonicalConfig); + }); +});