diff --git a/crates/sui-graphql-e2e-tests/tests/owner/root_version.exp b/crates/sui-graphql-e2e-tests/tests/owner/root_version.exp new file mode 100644 index 0000000000000..14790da6f9e58 --- /dev/null +++ b/crates/sui-graphql-e2e-tests/tests/owner/root_version.exp @@ -0,0 +1,292 @@ +processed 17 tasks + +init: +A: object(0,0) + +task 1 'publish'. lines 6-86: +created: object(1,0) +mutated: object(0,1) +gas summary: computation_cost: 1000000, storage_cost: 10586800, storage_rebate: 0, non_refundable_storage_fee: 0 + +task 2 'run'. lines 88-89: +created: object(2,0) +mutated: object(0,1) +gas summary: computation_cost: 1000000, storage_cost: 2264800, storage_rebate: 978120, non_refundable_storage_fee: 9880 + +task 3 'run'. lines 91-92: +created: object(3,0) +mutated: object(0,1) +gas summary: computation_cost: 1000000, storage_cost: 2257200, storage_rebate: 978120, non_refundable_storage_fee: 9880 + +task 4 'run'. lines 94-95: +created: object(4,0) +mutated: object(0,1) +gas summary: computation_cost: 1000000, storage_cost: 2272400, storage_rebate: 978120, non_refundable_storage_fee: 9880 + +task 5 'run'. lines 97-98: +created: object(5,0) +mutated: object(0,1) +gas summary: computation_cost: 1000000, storage_cost: 2272400, storage_rebate: 978120, non_refundable_storage_fee: 9880 + +task 6 'run'. lines 101-104: +created: object(6,0), object(6,1) +mutated: object(0,1), object(2,0), object(4,0), object(5,0) +wrapped: object(3,0) +gas summary: computation_cost: 1000000, storage_cost: 9940800, storage_rebate: 6041772, non_refundable_storage_fee: 61028 + +task 7 'view-object'. lines 106-108: +Owner: Account Address ( _ ) +Version: 7 +Contents: P0::M::O { + id: sui::object::UID { + id: sui::object::ID { + bytes: fake(2,0), + }, + }, + count: 0u64, + wrapped: std::option::Option { + vec: vector[ + P0::M::W { + id: sui::object::UID { + id: sui::object::ID { + bytes: fake(3,0), + }, + }, + count: 0u64, + }, + ], + }, +} + +task 8 'run'. lines 110-111: +mutated: object(0,1), object(2,0) +gas summary: computation_cost: 1000000, storage_cost: 2568800, storage_rebate: 2543112, non_refundable_storage_fee: 25688 + +task 9 'run'. lines 113-114: +mutated: object(0,1), object(2,0) +gas summary: computation_cost: 1000000, storage_cost: 2568800, storage_rebate: 2543112, non_refundable_storage_fee: 25688 + +task 10 'run'. lines 116-117: +mutated: object(0,1), object(2,0), object(4,0) +gas summary: computation_cost: 1000000, storage_cost: 3853200, storage_rebate: 3814668, non_refundable_storage_fee: 38532 + +task 11 'run'. lines 119-120: +mutated: object(0,1), object(2,0), object(5,0) +gas summary: computation_cost: 1000000, storage_cost: 3853200, storage_rebate: 3814668, non_refundable_storage_fee: 38532 + +task 12 'view-object'. lines 122-122: +Owner: Account Address ( _ ) +Version: 11 +Contents: P0::M::O { + id: sui::object::UID { + id: sui::object::ID { + bytes: fake(2,0), + }, + }, + count: 1u64, + wrapped: std::option::Option { + vec: vector[ + P0::M::W { + id: sui::object::UID { + id: sui::object::ID { + bytes: fake(3,0), + }, + }, + count: 1u64, + }, + ], + }, +} + +task 13 'create-checkpoint'. lines 124-124: +Checkpoint created: 1 + +task 14 'run-graphql'. lines 126-141: +Response: { + "data": { + "latest": { + "asObject": { + "asMoveObject": { + "version": 11, + "contents": { + "json": { + "id": "0x90dc651cc4aef34057af8c944f0ab8a0150295ad088edd4407a1bd4c225e18b8", + "count": "1", + "wrapped": { + "id": "0x3330add01caca066f647d3a3df92917d0191ba9b9aaaa8d2da4d726bb3c330cf", + "count": "1" + } + } + } + } + } + }, + "versioned": { + "asObject": { + "asMoveObject": { + "version": 10, + "contents": { + "json": { + "id": "0x90dc651cc4aef34057af8c944f0ab8a0150295ad088edd4407a1bd4c225e18b8", + "count": "1", + "wrapped": { + "id": "0x3330add01caca066f647d3a3df92917d0191ba9b9aaaa8d2da4d726bb3c330cf", + "count": "1" + } + } + } + } + } + }, + "beforeWrappedBump": { + "asObject": { + "asMoveObject": { + "version": 8, + "contents": { + "json": { + "id": "0x90dc651cc4aef34057af8c944f0ab8a0150295ad088edd4407a1bd4c225e18b8", + "count": "1", + "wrapped": { + "id": "0x3330add01caca066f647d3a3df92917d0191ba9b9aaaa8d2da4d726bb3c330cf", + "count": "0" + } + } + } + } + } + }, + "beforeBump": { + "asObject": { + "asMoveObject": { + "version": 7, + "contents": { + "json": { + "id": "0x90dc651cc4aef34057af8c944f0ab8a0150295ad088edd4407a1bd4c225e18b8", + "count": "0", + "wrapped": { + "id": "0x3330add01caca066f647d3a3df92917d0191ba9b9aaaa8d2da4d726bb3c330cf", + "count": "0" + } + } + } + } + } + } + } +} + +task 15 'run-graphql'. lines 143-171: +Response: { + "data": { + "unversioned": { + "dynamicObjectField": { + "value": { + "version": 7, + "contents": { + "json": { + "id": "0xd38f2f2e8c369c5ec08b8852fac5834c2bcc25308f0205763528364354dc0369", + "count": "0" + } + } + } + } + }, + "latest": { + "dynamicObjectField": { + "value": { + "version": 10, + "contents": { + "json": { + "id": "0xd38f2f2e8c369c5ec08b8852fac5834c2bcc25308f0205763528364354dc0369", + "count": "1" + } + } + } + } + }, + "afterFirstInnerBump": { + "dynamicObjectField": { + "value": { + "version": 10, + "contents": { + "json": { + "id": "0xd38f2f2e8c369c5ec08b8852fac5834c2bcc25308f0205763528364354dc0369", + "count": "1" + } + } + } + } + }, + "beforeFirstInnerBump": { + "dynamicObjectField": { + "value": { + "version": 7, + "contents": { + "json": { + "id": "0xd38f2f2e8c369c5ec08b8852fac5834c2bcc25308f0205763528364354dc0369", + "count": "0" + } + } + } + } + }, + "beforeBumps": { + "dynamicObjectField": { + "value": { + "version": 7, + "contents": { + "json": { + "id": "0xd38f2f2e8c369c5ec08b8852fac5834c2bcc25308f0205763528364354dc0369", + "count": "0" + } + } + } + } + } + } +} + +task 16 'run-graphql'. lines 173-194: +Response: { + "data": { + "unversioned": { + "dynamicObjectField": { + "value": { + "version": 7, + "contents": { + "json": { + "id": "0xab491044d75a8be613bd6fdc2215a0847c740b8774bc6feb9188f4b7233a37d5", + "count": "0" + } + } + } + } + }, + "latest": { + "dynamicObjectField": { + "value": { + "version": 11, + "contents": { + "json": { + "id": "0xab491044d75a8be613bd6fdc2215a0847c740b8774bc6feb9188f4b7233a37d5", + "count": "1" + } + } + } + } + }, + "beforeInnerBump": { + "dynamicObjectField": { + "value": { + "version": 7, + "contents": { + "json": { + "id": "0xab491044d75a8be613bd6fdc2215a0847c740b8774bc6feb9188f4b7233a37d5", + "count": "0" + } + } + } + } + } + } +} diff --git a/crates/sui-graphql-e2e-tests/tests/owner/root_version.move b/crates/sui-graphql-e2e-tests/tests/owner/root_version.move new file mode 100644 index 0000000000000..4a0c77bb5de35 --- /dev/null +++ b/crates/sui-graphql-e2e-tests/tests/owner/root_version.move @@ -0,0 +1,194 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//# init --protocol-version 48 --addresses P0=0x0 --accounts A --simulator + +//# publish +module P0::M { + use sui::dynamic_object_field as dof; + + public struct O has key { + id: UID, + count: u64, + wrapped: Option, + } + + public struct W has key, store { + id: UID, + count: u64, + } + + public struct DOF has key, store { + id: UID, + count: u64, + } + + // Constructors for each part of the chain + + entry fun new_o(ctx: &mut TxContext) { + transfer::transfer( + O { + id: object::new(ctx), + wrapped: option::none(), + count: 0, + }, + ctx.sender(), + ); + } + + entry fun new_w(ctx: &mut TxContext) { + transfer::transfer( + W { id: object::new(ctx), count: 0 }, + ctx.sender(), + ); + } + + entry fun new_dof(ctx: &mut TxContext) { + transfer::transfer( + DOF { id: object::new(ctx), count: 0 }, + ctx.sender(), + ); + } + + entry fun connect(o: &mut O, mut w: W, mut inner: DOF, outer: DOF) { + dof::add(&mut inner.id, false, outer); + dof::add(&mut w.id, false, inner); + o.wrapped.fill(w); + } + + /// Touch just the outer object (nothing else changes). + entry fun touch_root(o: &mut O) { + o.count = o.count + 1; + } + + /// Touch the wrapped object. + entry fun touch_wrapped(o: &mut O) { + let w = o.wrapped.borrow_mut(); + w.count = w.count + 1; + } + + /// Touch the inner dynamic object field. + entry fun touch_inner(o: &mut O) { + let w = o.wrapped.borrow_mut(); + let inner: &mut DOF = dof::borrow_mut(&mut w.id, false); + inner.count = inner.count + 1; + } + + /// Touch the inner dynamic object field. + entry fun touch_outer(o: &mut O) { + let w = o.wrapped.borrow_mut(); + let inner: &mut DOF = dof::borrow_mut(&mut w.id, false); + let outer: &mut DOF = dof::borrow_mut(&mut inner.id, false); + outer.count = outer.count + 1; + } +} + +// Create all the objects + +//# run P0::M::new_o +// lamport version: 3 (o) + +//# run P0::M::new_w +// lamport version: 4 (w) + +//# run P0::M::new_dof +// lamport version: 5 (inner) + +//# run P0::M::new_dof +// lamprot version: 6 (outer) + + +//# run P0::M::connect --args object(2,0) object(3,0) object(4,0) object(5,0) +// lamport version: 7 (o, w, inner, outer) +// Create a chain from the created objects: +// o -(wraps)-> w -(dof)-> inner -(dof)-> outer + +//# view-object 2,0 + +// Nudge each level of the chain in turn: + +//# run P0::M::touch_root --args object(2,0) +// lamport version: 8 (o) + +//# run P0::M::touch_wrapped --args object(2,0) +// lamport version: 9 (o) + +//# run P0::M::touch_inner --args object(2,0) +// lamport version: 10 (o, inner) + +//# run P0::M::touch_outer --args object(2,0) +// lamport version: 11 (o, outer) + +//# view-object 2,0 + +//# create-checkpoint + +//# run-graphql +fragment Obj on Owner { + asObject { + asMoveObject { + version + contents { json } + } + } +} + +{ # Queries for the root object + latest: owner(address: "@{obj_2_0}") { ...Obj } + versioned: owner(address: "@{obj_2_0}", rootVersion: 10) { ...Obj } + beforeWrappedBump: owner(address: "@{obj_2_0}", rootVersion: 8) { ...Obj } + beforeBump: owner(address: "@{obj_2_0}", rootVersion: 7) { ...Obj } +} + +//# run-graphql +fragment DOF on Owner { + dynamicObjectField(name: { type: "bool", bcs: "AA==" }) { + value { + ... on MoveObject { + version + contents { json } + } + } + } +} + +{ # Querying dynamic fields under the wrapped Move object + # AA== is the base64 encoding of the boolean value `false` (0x00). + + # The latest version konwn to the service for the wrapped object is + # the version it was wrapped at, so that will have no dynamic + # fields. + unversioned: owner(address: "@{obj_3_0}") { ...DOF } + + # Specifying the latest version of the wrapping object has the + # desired effect. + latest: owner(address: "@{obj_3_0}", rootVersion: 11) { ...DOF } + + # Look at various versions of the object in history + afterFirstInnerBump: owner(address: "@{obj_3_0}", rootVersion: 10) { ...DOF } + beforeFirstInnerBump: owner(address: "@{obj_3_0}", rootVersion: 9) { ...DOF } + beforeBumps: owner(address: "@{obj_3_0}", rootVersion: 7) { ...DOF } +} + +//# run-graphql +fragment DOF on Owner { + dynamicObjectField(name: { type: "bool", bcs: "AA==" }) { + value { + ... on MoveObject { + version + contents { json } + } + } + } +} + +{ # Querying a nested dynamic field, where the version of the child + # may be greater than the version of its immediate parent + + # At its latest version, it doesn't see the latest change on its child. + unversioned: owner(address: "@{obj_4_0}") { ...DOF } + + # But at its root's latest version, it does + latest: owner(address: "@{obj_4_0}", rootVersion: 11) { ...DOF } + beforeInnerBump: owner(address: "@{obj_4_0}", rootVersion: 10) { ...DOF } +} diff --git a/crates/sui-graphql-rpc/schema/current_progress_schema.graphql b/crates/sui-graphql-rpc/schema/current_progress_schema.graphql index c6b25c69ebe35..c5b5ae6e526c0 100644 --- a/crates/sui-graphql-rpc/schema/current_progress_schema.graphql +++ b/crates/sui-graphql-rpc/schema/current_progress_schema.graphql @@ -3016,7 +3016,24 @@ type Query { non-entry functions, and some other checks. Defaults to false. """ dryRunTransactionBlock(txBytes: String!, txMeta: TransactionMetadata, skipChecks: Boolean): DryRunResult! - owner(address: SuiAddress!): Owner + """ + Look up an Owner by its SuiAddress. + + `rootVersion` represents the version of the root object in some nested chain of dynamic + fields. It allows consistent historical queries for the case of wrapped objects, which don't + have a version. For example, if querying the dynamic field of a table wrapped in a parent + object, passing the parent object's version here will ensure we get the dynamic field's + state at the moment that parent's version was created. + + Also, if this Owner is an object itself, `rootVersion` will be used to bound its version + from above when querying `Owner.asObject`. This can be used, for example, to get the + contents of a dynamic object field when its parent was at `rootVersion`. + + If `rootVersion` is omitted, dynamic fields will be from a consistent snapshot of the Sui + state at the latest checkpoint known to the GraphQL RPC. Similarly, `Owner.asObject` will + return the object's version at the latest checkpoint. + """ + owner(address: SuiAddress!, rootVersion: Int): Owner """ The object corresponding to the given address at the (optionally) given version. When no version is given, the latest version is returned. diff --git a/crates/sui-graphql-rpc/src/types/balance_change.rs b/crates/sui-graphql-rpc/src/types/balance_change.rs index 98a31d01bb811..bf152e12446c4 100644 --- a/crates/sui-graphql-rpc/src/types/balance_change.rs +++ b/crates/sui-graphql-rpc/src/types/balance_change.rs @@ -25,6 +25,7 @@ impl BalanceChange { O::AddressOwner(addr) | O::ObjectOwner(addr) => Some(Owner { address: SuiAddress::from(addr), checkpoint_viewed_at: self.checkpoint_viewed_at, + root_version: None, }), O::Shared { .. } | O::Immutable => None, diff --git a/crates/sui-graphql-rpc/src/types/object.rs b/crates/sui-graphql-rpc/src/types/object.rs index 8c094c1665334..7243666ef520c 100644 --- a/crates/sui-graphql-rpc/src/types/object.rs +++ b/crates/sui-graphql-rpc/src/types/object.rs @@ -553,6 +553,7 @@ impl ObjectImpl<'_> { owner: Some(Owner { address, checkpoint_viewed_at: self.0.checkpoint_viewed_at, + root_version: None, }), })) } diff --git a/crates/sui-graphql-rpc/src/types/owner.rs b/crates/sui-graphql-rpc/src/types/owner.rs index be94e38311fb9..79525ca9e9921 100644 --- a/crates/sui-graphql-rpc/src/types/owner.rs +++ b/crates/sui-graphql-rpc/src/types/owner.rs @@ -28,6 +28,21 @@ pub(crate) struct Owner { pub address: SuiAddress, /// The checkpoint sequence number at which this was viewed at. pub checkpoint_viewed_at: u64, + /// Root parent object version for dynamic fields. + /// + /// This enables consistent dynamic field reads in the case of chained dynamic object fields, + /// e.g., `Parent -> DOF1 -> DOF2`. In such cases, the object versions may end up like + /// `Parent >= DOF1, DOF2` but `DOF1 < DOF2`. Thus, database queries for dynamic fields must + /// bound the object versions by the version of the root object of the tree. + /// + /// Also, if this Owner is an object itself, `root_version` will be used to bound its version + /// from above in [`Owner::as_object`]. + /// + /// Essentially, lamport timestamps of objects are updated for all top-level mutable objects + /// provided as inputs to a transaction as well as any mutated dynamic child objects. However, + /// any dynamic child objects that were loaded but not actually mutated don't end up having + /// their versions updated. + pub root_version: Option, } /// Type to implement GraphQL fields that are shared by all Owners. @@ -236,7 +251,10 @@ impl Owner { Object::query( ctx, self.address, - Object::latest_at(self.checkpoint_viewed_at), + object::ObjectLookup::LatestAt { + parent_version: self.root_version, + checkpoint_viewed_at: self.checkpoint_viewed_at, + }, ) .await .extend() @@ -253,7 +271,7 @@ impl Owner { name: DynamicFieldName, ) -> Result> { OwnerImpl::from(self) - .dynamic_field(ctx, name, /* parent_version */ None) + .dynamic_field(ctx, name, self.root_version) .await } @@ -269,7 +287,7 @@ impl Owner { name: DynamicFieldName, ) -> Result> { OwnerImpl::from(self) - .dynamic_object_field(ctx, name, /* parent_version */ None) + .dynamic_object_field(ctx, name, self.root_version) .await } @@ -285,9 +303,7 @@ impl Owner { before: Option, ) -> Result> { OwnerImpl::from(self) - .dynamic_fields( - ctx, first, after, last, before, /* parent_version */ None, - ) + .dynamic_fields(ctx, first, after, last, before, self.root_version) .await } } diff --git a/crates/sui-graphql-rpc/src/types/query.rs b/crates/sui-graphql-rpc/src/types/query.rs index 2deef96ce830a..f10d28f06236f 100644 --- a/crates/sui-graphql-rpc/src/types/query.rs +++ b/crates/sui-graphql-rpc/src/types/query.rs @@ -176,11 +176,32 @@ impl Query { DryRunResult::try_from(res).extend() } - async fn owner(&self, ctx: &Context<'_>, address: SuiAddress) -> Result> { + /// Look up an Owner by its SuiAddress. + /// + /// `rootVersion` represents the version of the root object in some nested chain of dynamic + /// fields. It allows consistent historical queries for the case of wrapped objects, which don't + /// have a version. For example, if querying the dynamic field of a table wrapped in a parent + /// object, passing the parent object's version here will ensure we get the dynamic field's + /// state at the moment that parent's version was created. + /// + /// Also, if this Owner is an object itself, `rootVersion` will be used to bound its version + /// from above when querying `Owner.asObject`. This can be used, for example, to get the + /// contents of a dynamic object field when its parent was at `rootVersion`. + /// + /// If `rootVersion` is omitted, dynamic fields will be from a consistent snapshot of the Sui + /// state at the latest checkpoint known to the GraphQL RPC. Similarly, `Owner.asObject` will + /// return the object's version at the latest checkpoint. + async fn owner( + &self, + ctx: &Context<'_>, + address: SuiAddress, + root_version: Option, + ) -> Result> { let Watermark { checkpoint, .. } = *ctx.data()?; Ok(Some(Owner { address, checkpoint_viewed_at: checkpoint, + root_version, })) } diff --git a/crates/sui-graphql-rpc/src/types/validator.rs b/crates/sui-graphql-rpc/src/types/validator.rs index 6dff4af5eac78..86addc51d15c0 100644 --- a/crates/sui-graphql-rpc/src/types/validator.rs +++ b/crates/sui-graphql-rpc/src/types/validator.rs @@ -234,6 +234,7 @@ impl Validator { Ok(Some(Owner { address: self.validator_summary.exchange_rates_id.into(), checkpoint_viewed_at: self.checkpoint_viewed_at, + root_version: None, })) } diff --git a/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap b/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap index 565c450b28637..7746f8124de1c 100644 --- a/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap +++ b/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap @@ -3020,7 +3020,24 @@ type Query { non-entry functions, and some other checks. Defaults to false. """ dryRunTransactionBlock(txBytes: String!, txMeta: TransactionMetadata, skipChecks: Boolean): DryRunResult! - owner(address: SuiAddress!): Owner + """ + Look up an Owner by its SuiAddress. + + `rootVersion` represents the version of the root object in some nested chain of dynamic + fields. It allows consistent historical queries for the case of wrapped objects, which don't + have a version. For example, if querying the dynamic field of a table wrapped in a parent + object, passing the parent object's version here will ensure we get the dynamic field's + state at the moment that parent's version was created. + + Also, if this Owner is an object itself, `rootVersion` will be used to bound its version + from above when querying `Owner.asObject`. This can be used, for example, to get the + contents of a dynamic object field when its parent was at `rootVersion`. + + If `rootVersion` is omitted, dynamic fields will be from a consistent snapshot of the Sui + state at the latest checkpoint known to the GraphQL RPC. Similarly, `Owner.asObject` will + return the object's version at the latest checkpoint. + """ + owner(address: SuiAddress!, rootVersion: Int): Owner """ The object corresponding to the given address at the (optionally) given version. When no version is given, the latest version is returned.