From 9ddff13235e06d9928628248feb7f30436a898e5 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Wed, 4 Dec 2024 12:54:50 +0100 Subject: [PATCH] Add functions for validating gov actions --- cardano-api/cardano-api.cabal | 6 + .../internal/Cardano/Api/Certificate.hs | 14 ++ .../internal/Cardano/Api/DRepMetadata.hs | 5 + .../Governance/Metadata/DrepRegistration.hs | 132 ++++++++++++++ .../Api/Governance/Metadata/GovAction.hs | 164 ++++++++++++++++++ .../Api/Governance/Metadata/Parsers.hs | 22 +++ .../Api/Governance/Metadata/Validation.hs | 29 ++++ cardano-api/src/Cardano/Api.hs | 17 +- .../Test/Cardano/Api/Experimental.hs | 126 ++++++++++++++ .../Test/Cardano/Api/GovAnchorValidation.hs | 112 ++++++++++++ .../test/cardano-api-test/cardano-api-test.hs | 2 + .../invalid-drep-metadata.jsonld | 26 +++ .../gov-anchor-data/no-confidence.jsonld | 76 ++++++++ .../threasury-withdrawal.jsonld | 73 ++++++++ .../too-long-given-name-drep-metadata.jsonld | 17 ++ .../too-long-title-treasury-withdraw.jsonld | 66 +++++++ .../valid-drep-metadata.jsonld | 58 +++++++ 17 files changed, 942 insertions(+), 3 deletions(-) create mode 100644 cardano-api/internal/Cardano/Api/Governance/Metadata/DrepRegistration.hs create mode 100644 cardano-api/internal/Cardano/Api/Governance/Metadata/GovAction.hs create mode 100644 cardano-api/internal/Cardano/Api/Governance/Metadata/Parsers.hs create mode 100644 cardano-api/internal/Cardano/Api/Governance/Metadata/Validation.hs create mode 100644 cardano-api/test/cardano-api-test/Test/Cardano/Api/Experimental.hs create mode 100644 cardano-api/test/cardano-api-test/Test/Cardano/Api/GovAnchorValidation.hs create mode 100644 cardano-api/test/cardano-api-test/files/input/gov-anchor-data/invalid-drep-metadata.jsonld create mode 100644 cardano-api/test/cardano-api-test/files/input/gov-anchor-data/no-confidence.jsonld create mode 100644 cardano-api/test/cardano-api-test/files/input/gov-anchor-data/threasury-withdrawal.jsonld create mode 100644 cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-given-name-drep-metadata.jsonld create mode 100644 cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-title-treasury-withdraw.jsonld create mode 100644 cardano-api/test/cardano-api-test/files/input/gov-anchor-data/valid-drep-metadata.jsonld diff --git a/cardano-api/cardano-api.cabal b/cardano-api/cardano-api.cabal index f64a1ed28c..6cf5bae9b8 100644 --- a/cardano-api/cardano-api.cabal +++ b/cardano-api/cardano-api.cabal @@ -92,6 +92,10 @@ library internal Cardano.Api.GenesisParameters Cardano.Api.Governance.Actions.ProposalProcedure Cardano.Api.Governance.Actions.VotingProcedure + Cardano.Api.Governance.Metadata.DrepRegistration + Cardano.Api.Governance.Metadata.GovAction + Cardano.Api.Governance.Metadata.Parsers + Cardano.Api.Governance.Metadata.Validation Cardano.Api.Governance.Poll Cardano.Api.HasTypeProxy Cardano.Api.Hash @@ -359,7 +363,9 @@ test-suite cardano-api-test Test.Cardano.Api.Envelope Test.Cardano.Api.EpochLeadership Test.Cardano.Api.Eras + Test.Cardano.Api.Experimental Test.Cardano.Api.Genesis + Test.Cardano.Api.GovAnchorValidation Test.Cardano.Api.IO Test.Cardano.Api.Json Test.Cardano.Api.KeysByron diff --git a/cardano-api/internal/Cardano/Api/Certificate.hs b/cardano-api/internal/Cardano/Api/Certificate.hs index b31ab11135..5024a933d5 100644 --- a/cardano-api/internal/Cardano/Api/Certificate.hs +++ b/cardano-api/internal/Cardano/Api/Certificate.hs @@ -73,6 +73,7 @@ module Cardano.Api.Certificate -- * Internal functions , filterUnRegCreds , filterUnRegDRepCreds + , isDRepRegOrUpdateCert ) where @@ -793,3 +794,16 @@ getAnchorDataFromCertificate c = , Ledger.anchorDataHash = Ledger.unsafeMakeSafeHash hash } ) + +-- | Returns `True` if the certificate is a DRep registration or update certificate, +-- otherwise `False`. This is to see if the certificate needs to be compliant with +-- CIP-0119. +isDRepRegOrUpdateCert :: Certificate era -> Bool +isDRepRegOrUpdateCert = \case + ShelleyRelatedCertificate _ _ -> False + ConwayCertificate ceo ccert -> + conwayEraOnwardsConstraints ceo $ + case ccert of + Ledger.RegDRepTxCert{} -> True + Ledger.UpdateDRepTxCert{} -> True + _ -> False diff --git a/cardano-api/internal/Cardano/Api/DRepMetadata.hs b/cardano-api/internal/Cardano/Api/DRepMetadata.hs index d883d311ff..51842e455e 100644 --- a/cardano-api/internal/Cardano/Api/DRepMetadata.hs +++ b/cardano-api/internal/Cardano/Api/DRepMetadata.hs @@ -1,4 +1,5 @@ {-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE InstanceSigs #-} {-# LANGUAGE TypeFamilies #-} -- | DRep off-chain metadata @@ -43,11 +44,15 @@ newtype instance Hash DRepMetadata = DRepMetadataHash (Shelley.Hash StandardCryp instance HasTypeProxy DRepMetadata where data AsType DRepMetadata = AsDRepMetadata + proxyToAsType :: Proxy DRepMetadata -> AsType DRepMetadata proxyToAsType _ = AsDRepMetadata instance SerialiseAsRawBytes (Hash DRepMetadata) where + serialiseToRawBytes :: Hash DRepMetadata -> ByteString serialiseToRawBytes (DRepMetadataHash h) = Crypto.hashToBytes h + deserialiseFromRawBytes + :: AsType (Hash DRepMetadata) -> ByteString -> Either SerialiseAsRawBytesError (Hash DRepMetadata) deserialiseFromRawBytes (AsHash AsDRepMetadata) bs = maybeToRight (SerialiseAsRawBytesError "Unable to deserialise Hash DRepMetadata") $ DRepMetadataHash <$> Crypto.hashFromBytes bs diff --git a/cardano-api/internal/Cardano/Api/Governance/Metadata/DrepRegistration.hs b/cardano-api/internal/Cardano/Api/Governance/Metadata/DrepRegistration.hs new file mode 100644 index 0000000000..69cc00ac24 --- /dev/null +++ b/cardano-api/internal/Cardano/Api/Governance/Metadata/DrepRegistration.hs @@ -0,0 +1,132 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE InstanceSigs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE TypeFamilies #-} + +module Cardano.Api.Governance.Metadata.DrepRegistration (CIP119 (..)) where +-- | DRep off-chain metadata +-- +-- This module implements validation of metadata for DRep registration and +-- update actions, as specified bt the CIP-119 (https://cips.cardano.org/cip/CIP-0119). +-- +-- The constraints implemented in this module can be tested against a JSON +-- 'ByteString' by using the function 'validateGovActionAnchorData' in +-- "Cardano.Api.Governance.Metadata.Validation" with the parameter 'DrepRegistrationMetadata'. + +import Cardano.Api.Governance.Metadata.Parsers (textWithMaxLength) +import Cardano.Api.Governance.Metadata.Validation (Authors, Body, GovActionMetadata (..), + HashAlgorithm) + +import Data.Aeson (FromJSON, withObject, (.:), (.:?)) +import qualified Data.Aeson as Aeson +import Data.Aeson.Types (Parser) +import Data.Text (Text) +import GHC.Generics (Generic) + +data CIP119 = DrepRegistrationMetadata + +instance FromJSON (GovActionMetadata CIP119) where + parseJSON :: Aeson.Value -> Parser (GovActionMetadata CIP119) + parseJSON = withObject "CIP119Common" $ \v -> + GovActionMetadata + <$> v .: "hashAlgorithm" + <*> pure Absent + <*> v .: "body" + +-- Hash Algorithm (Enum) +data instance HashAlgorithm CIP119 = Blake2b256 + deriving (Show, Generic) + +instance FromJSON (HashAlgorithm CIP119) where + parseJSON :: Aeson.Value -> Parser (HashAlgorithm CIP119) + parseJSON = Aeson.withText "HashAlgorithm" $ + \case + "blake2b-256" -> return Blake2b256 + _ -> fail "Invalid hashAlgorithm, it must be: blake2b-256" + +-- Body of the metadata document +data instance Body CIP119 = Body + { paymentAddress :: Maybe Text + , givenName :: Text + , image :: Maybe ImageObject + , objectives :: Maybe Text + , motivations :: Maybe Text + , qualifications :: Maybe Text + , doNotList :: Maybe DoNotList + , references :: Maybe [Reference] + } + deriving (Show, Generic) + +instance FromJSON (Body CIP119) where + parseJSON :: Aeson.Value -> Parser (Body CIP119) + parseJSON = withObject "Body" $ \v -> + Body + <$> v .:? "paymentAddress" + <*> (v .: "givenName" >>= textWithMaxLength "givenName" 80) + <*> v .:? "image" + <*> (v .:? "objectives" >>= traverse (textWithMaxLength "objectives" 1000)) + <*> (v .:? "motivations" >>= traverse (textWithMaxLength "motivations" 1000)) + <*> (v .:? "qualifications" >>= traverse (textWithMaxLength "qualifications" 1000)) + <*> v .:? "doNotList" + <*> v .:? "references" + +-- Profile picture +data ImageObject = ImageObject + { contentUrl :: Text -- Base64 encoded image or URL + , sha256 :: Maybe Text -- Only present for URL images + } + deriving (Show, Generic) + +instance FromJSON ImageObject where + parseJSON :: Aeson.Value -> Parser ImageObject + parseJSON = withObject "ImageObject" $ \v -> + ImageObject + <$> v .: "contentUrl" + <*> v .:? "sha256" + +-- DoNotList Enum +data DoNotList = DoNotListTrue | DoNotListFalse + deriving (Show, Generic) + +instance FromJSON DoNotList where + parseJSON :: Aeson.Value -> Parser DoNotList + parseJSON = Aeson.withText "DoNotList" $ + \case + "true" -> return DoNotListTrue + "false" -> return DoNotListFalse + _ -> fail "Invalid doNotList value, must be one of: true, false" + +-- Reference type +data Reference = Reference + { refType :: ReferenceType + , label :: Text + , uri :: Text + } + deriving (Show, Generic) + +instance FromJSON Reference where + parseJSON :: Aeson.Value -> Parser Reference + parseJSON = withObject "Reference" $ \v -> + Reference + <$> v .: "@type" + <*> v .: "label" + <*> v .: "uri" + +-- ReferenceType Enum +data ReferenceType = GovernanceMetadata | Other | Link | Identity + deriving (Show, Generic) + +instance FromJSON ReferenceType where + parseJSON :: Aeson.Value -> Parser ReferenceType + parseJSON = Aeson.withText "ReferenceType" $ + \case + "GovernanceMetadata" -> return GovernanceMetadata + "Other" -> return Other + "Link" -> return Link + "Identity" -> return Identity + _ -> + fail "Invalid reference type, must be one of: GovernanceMetadata, Other, Link, Identity" + +-- We don't need to validate Authors because it is optional in CIP-119 +data instance Authors CIP119 = Absent diff --git a/cardano-api/internal/Cardano/Api/Governance/Metadata/GovAction.hs b/cardano-api/internal/Cardano/Api/Governance/Metadata/GovAction.hs new file mode 100644 index 0000000000..e94ac8bbba --- /dev/null +++ b/cardano-api/internal/Cardano/Api/Governance/Metadata/GovAction.hs @@ -0,0 +1,164 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE InstanceSigs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE TypeFamilies #-} + +module Cardano.Api.Governance.Metadata.GovAction (CIP108 (..)) where +-- | Government action metadata +-- +-- This module implements validation of metadata for Government Actions in +-- general, as specified bt the CIP-108 (https://cips.cardano.org/cip/CIP-0108), +-- except for Government Actions covered by other CIPs. +-- +-- The constraints implemented in this module can be tested against a JSON +-- 'ByteString' by using the function 'validateGovActionAnchorData' in +-- "Cardano.Api.Governance.Metadata.Validation" with the parameter 'BaseGovActionMetadata'. + +import Cardano.Api.Governance.Metadata.Parsers (textWithMaxLength) +import Cardano.Api.Governance.Metadata.Validation (Authors, Body, GovActionMetadata (..), + HashAlgorithm) + +import Data.Aeson (FromJSON, withArray, withObject, withText, (.:), (.:?)) +import qualified Data.Aeson as Aeson +import Data.Aeson.Types (Parser, Value (..)) +import Data.Text (Text) +import GHC.Generics (Generic) + +data CIP108 = BaseGovActionMetadata + +instance FromJSON (GovActionMetadata CIP108) where + parseJSON :: Value -> Parser (GovActionMetadata CIP108) + parseJSON = withObject "CIP108Common" $ \v -> + GovActionMetadata + <$> v .: "hashAlgorithm" + <*> v .: "authors" + <*> v .: "body" + +-- Enum for HashAlgorithm + +data instance HashAlgorithm CIP108 = Blake2b256 + deriving (Show, Generic) + +instance FromJSON (HashAlgorithm CIP108) where + parseJSON :: Value -> Parser (HashAlgorithm CIP108) + parseJSON = withText "HashAlgorithm" $ + \case + "blake2b-256" -> return Blake2b256 + _ -> fail "Invalid hashAlgorithm value, must be: blake2b-256" + +-- Author object + +newtype instance Authors CIP108 = Authors [Author] + deriving (Show, Generic) + +instance FromJSON (Authors CIP108) where + parseJSON :: Value -> Parser (Authors CIP108) + parseJSON = withArray "Authors" $ \arr -> + Authors <$> Aeson.parseJSON (Array arr) + +data Author = Author + { name :: Maybe Text + , witness :: Witness + } + deriving (Show, Generic) + +instance FromJSON Author where + parseJSON :: Value -> Parser Author + parseJSON = withObject "Author" $ \v -> + Author + <$> v .:? "name" + <*> v .: "witness" + +-- Witness object +data Witness = Witness + { witnessAlgorithm :: Maybe WitnessAlgorithm + , publicKey :: Maybe Text + , signature :: Maybe Text + } + deriving (Show, Generic) + +instance FromJSON Witness where + parseJSON :: Value -> Parser Witness + parseJSON = withObject "Witness" $ \v -> + Witness + <$> v .:? "witnessAlgorithm" + <*> v .:? "publicKey" + <*> v .:? "signature" + +-- Enum for WitnessAlgorithm +data WitnessAlgorithm = Ed25519 | CIP0008 + deriving (Show, Generic) + +instance FromJSON WitnessAlgorithm where + parseJSON :: Value -> Parser WitnessAlgorithm + parseJSON = withText "WitnessAlgorithm" $ + \case + "ed25519" -> return Ed25519 + "CIP-0008" -> return CIP0008 + _ -> fail "Invalid witnessAlgorithm value, must be: ed25519 or CIP-0008" + +-- Body of the metadata document + +data instance Body CIP108 = Body + { title :: Text + , abstract :: Text + , motivation :: Text + , rationale :: Text + , references :: Maybe [Reference] + } + deriving (Show, Generic) + +instance FromJSON (Body CIP108) where + parseJSON :: Value -> Parser (Body CIP108) + parseJSON = withObject "Body" $ \v -> + Body + <$> (v .: "title" >>= textWithMaxLength "title" 80) + <*> (v .: "abstract" >>= textWithMaxLength "abstract" 2500) + <*> v .: "motivation" + <*> v .: "rationale" + <*> v .:? "references" + +-- Reference object +data Reference = Reference + { refType :: ReferenceType + , label :: Text + , uri :: Text + , referenceHash :: Maybe ReferenceHash + } + deriving (Show, Generic) + +instance FromJSON Reference where + parseJSON :: Value -> Parser Reference + parseJSON = withObject "Reference" $ \v -> + Reference + <$> v .: "@type" + <*> v .: "label" + <*> v .: "uri" + <*> v .:? "referenceHash" + +-- Enum for ReferenceType +data ReferenceType = GovernanceMetadata | Other + deriving (Show, Generic) + +instance FromJSON ReferenceType where + parseJSON :: Value -> Parser ReferenceType + parseJSON = withText "ReferenceType" $ + \case + "GovernanceMetadata" -> return GovernanceMetadata + "Other" -> return Other + _ -> fail "Invalid reference type, must be one of: GovernanceMetadata, Other" + +-- ReferenceHash object +data ReferenceHash = ReferenceHash + { referenceHashDigest :: Text + , referenceHashAlgorithm :: HashAlgorithm CIP108 + } + deriving (Show, Generic) + +instance FromJSON ReferenceHash where + parseJSON :: Value -> Parser ReferenceHash + parseJSON = withObject "ReferenceHash" $ \v -> + ReferenceHash + <$> v .: "hashDigest" + <*> v .: "hashAlgorithm" diff --git a/cardano-api/internal/Cardano/Api/Governance/Metadata/Parsers.hs b/cardano-api/internal/Cardano/Api/Governance/Metadata/Parsers.hs new file mode 100644 index 0000000000..3fc8dd2d7f --- /dev/null +++ b/cardano-api/internal/Cardano/Api/Governance/Metadata/Parsers.hs @@ -0,0 +1,22 @@ +module Cardano.Api.Governance.Metadata.Parsers (textWithMaxLength) where + +import Data.Aeson.Types (Parser, Value, parseJSON) +import Data.Text (Text) +import qualified Data.Text as T + +-- | Parser for 'Text' that validates that the number of characters is +-- under a given maximum. The 'String' parameter is meant to be the name +-- of the field in order to be able to give context in case of error. +textWithMaxLength :: String -> Int -> Value -> Parser Text +textWithMaxLength fieldName maxLen value = do + txt <- parseJSON value + if T.length txt <= maxLen + then pure txt + else + fail $ + "key \"" + ++ fieldName + ++ "\" exceeds maximum length of " + ++ show maxLen + ++ " characters. Got length: " + ++ show (T.length txt) diff --git a/cardano-api/internal/Cardano/Api/Governance/Metadata/Validation.hs b/cardano-api/internal/Cardano/Api/Governance/Metadata/Validation.hs new file mode 100644 index 0000000000..08d6b2fc68 --- /dev/null +++ b/cardano-api/internal/Cardano/Api/Governance/Metadata/Validation.hs @@ -0,0 +1,29 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeFamilies #-} + +module Cardano.Api.Governance.Metadata.Validation (GovActionMetadata (..), Authors, Body, HashAlgorithm, validateGovActionAnchorData) where + +import Data.Aeson (FromJSON, eitherDecodeStrict) +import Data.ByteString (ByteString) +import Data.Either.Combinators (mapRight) + +data GovActionMetadata cip + = GovActionMetadata + { hashAlgorithm :: HashAlgorithm cip + , authors :: Authors cip + , body :: Body cip + } + +data family Authors cip + +data family Body cip + +data family HashAlgorithm cip + +validateGovActionAnchorData + :: forall cip. FromJSON (GovActionMetadata cip) => cip -> ByteString -> Either String () +validateGovActionAnchorData cip bytes = mapRight (const ()) (decodeGovAction cip bytes) + where + decodeGovAction :: cip -> ByteString -> Either String (GovActionMetadata cip) + decodeGovAction _ = eitherDecodeStrict diff --git a/cardano-api/src/Cardano/Api.hs b/cardano-api/src/Cardano/Api.hs index dd5078cde1..d8673334a6 100644 --- a/cardano-api/src/Cardano/Api.hs +++ b/cardano-api/src/Cardano/Api.hs @@ -487,6 +487,15 @@ module Cardano.Api , TxMetadataJsonError (..) , TxMetadataJsonSchemaError (..) + -- * Governance action metadata + , CIP108 (..) + + -- ** DRep Metadata + , DRepMetadata + , DRepMetadataReference + , hashDRepMetadata + , CIP119 (..) + -- * Certificates , Certificate (..) @@ -517,6 +526,7 @@ module Cardano.Api -- ** Anchor data , AnchorDataFromCertificateError (..) , getAnchorDataFromCertificate + , isDRepRegOrUpdateCert -- * Rewards , DelegationsAndRewards (..) @@ -1031,12 +1041,10 @@ module Cardano.Api -- ** DReps , DRepKey , DRepExtendedKey - , DRepMetadata - , DRepMetadataReference - , hashDRepMetadata -- ** Governance actions , getAnchorDataFromGovernanceAction + , validateGovActionAnchorData -- ** Governance related certificates , AnchorDataHash (..) @@ -1089,6 +1097,9 @@ import Cardano.Api.Fees import Cardano.Api.Genesis import Cardano.Api.GenesisParameters import Cardano.Api.Governance.Actions.ProposalProcedure +import Cardano.Api.Governance.Metadata.DrepRegistration (CIP119 (..)) +import Cardano.Api.Governance.Metadata.GovAction (CIP108 (..)) +import Cardano.Api.Governance.Metadata.Validation import Cardano.Api.Hash import Cardano.Api.HasTypeProxy import Cardano.Api.InMode diff --git a/cardano-api/test/cardano-api-test/Test/Cardano/Api/Experimental.hs b/cardano-api/test/cardano-api-test/Test/Cardano/Api/Experimental.hs new file mode 100644 index 0000000000..e067cecd6f --- /dev/null +++ b/cardano-api/test/cardano-api-test/Test/Cardano/Api/Experimental.hs @@ -0,0 +1,126 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE NumericUnderscores #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeFamilies #-} + +module Test.Cardano.Api.Experimental + ( tests + ) +where + +import qualified Cardano.Api as Api +import Cardano.Api.Eon.ShelleyBasedEra (ShelleyBasedEraConstraints) +import qualified Cardano.Api.Experimental as Exp +import qualified Cardano.Api.Ledger as Ledger +import qualified Cardano.Api.Script as Script +import Cardano.Api.Tx.Sign (Tx (ShelleyTx)) + +import Lens.Micro ((&)) + +import Hedgehog (Property) +import qualified Hedgehog as H +import qualified Hedgehog.Extras as H +import Test.Tasty (TestTree, testGroup) +import Test.Tasty.Hedgehog (testProperty) + +-- | Tests in this module can be run by themselves by writing: +-- ```bash +-- cabal test cardano-api-test --test-options="--pattern=Test.Cardano.Api.Experimental" +-- ``` +-- +-- IMPORTANT NOTE: If this file requires changes, please update the examples in the +-- documentation in 'cardano-api/src/Cardano/Api/Experimental.hs' too. +tests :: TestTree +tests = + testGroup + "Test.Cardano.Api.Experimental" + [ testProperty + "Created transaction with traditional and experimental APIs are equivalent" + prop_created_transaction_with_both_apis_are_the_same + ] + +prop_created_transaction_with_both_apis_are_the_same :: Property +prop_created_transaction_with_both_apis_are_the_same = H.propertyOnce $ do + let era = Exp.ConwayEra + let sbe = Api.convert era + + signedTxTraditional <- exampleTransacitonTraditionalWay sbe + signedTxExperimental <- exampleTransactionExperimentalWay era sbe + + let oldStyleTx :: Api.Tx Api.ConwayEra = ShelleyTx sbe signedTxExperimental + + oldStyleTx H.=== signedTxTraditional + where + exampleTxBodyContent + :: (ShelleyBasedEraConstraints era, H.MonadTest m) + => Api.AsType era + -> Api.ShelleyBasedEra era + -> m (Api.TxBodyContent Api.BuildTx era) + exampleTxBodyContent eraAsType sbe = do + srcTxId <- + H.evalEither $ + Api.deserialiseFromRawBytesHex + Api.AsTxId + "be6efd42a3d7b9a00d09d77a5d41e55ceaf0bd093a8aa8a893ce70d9caafd978" + let srcTxIx = Api.TxIx 0 + destAddress <- + H.evalMaybe $ + Api.deserialiseAddress + (Api.AsAddressInEra eraAsType) + "addr_test1vzpfxhjyjdlgk5c0xt8xw26avqxs52rtf69993j4tajehpcue4v2v" + + let txBodyContent = + Api.defaultTxBodyContent sbe + & Api.setTxIns + [ + ( Api.TxIn srcTxId srcTxIx + , Api.BuildTxWith (Api.KeyWitness Api.KeyWitnessForSpending) + ) + ] + & Api.setTxOuts + [ Api.TxOut + destAddress + (Api.TxOutValueShelleyBased sbe (Api.inject (Ledger.Coin 10_000_000))) + Api.TxOutDatumNone + Script.ReferenceScriptNone + ] + & Api.setTxFee (Api.TxFeeExplicit sbe (Ledger.Coin 2_000_000)) + + return txBodyContent + + exampleSigningKey :: H.MonadTest m => m (Api.SigningKey Api.PaymentKey) + exampleSigningKey = + H.evalEither $ + Api.deserialiseFromBech32 + (Api.AsSigningKey Api.AsPaymentKey) + "addr_sk1648253w4tf6fv5fk28dc7crsjsaw7d9ymhztd4favg3cwkhz7x8sl5u3ms" + + exampleTransacitonTraditionalWay + :: H.MonadTest m => Api.ShelleyBasedEra Exp.ConwayEra -> m (Tx Exp.ConwayEra) + exampleTransacitonTraditionalWay sbe = do + txBodyContent <- exampleTxBodyContent Api.AsConwayEra sbe + signingKey <- exampleSigningKey + + txBody <- H.evalEither $ Api.createTransactionBody sbe txBodyContent + + let signedTx :: Api.Tx Api.ConwayEra = Api.signShelleyTransaction sbe txBody [Api.WitnessPaymentKey signingKey] + + return signedTx + + exampleTransactionExperimentalWay + :: H.MonadTest m + => Exp.Era Exp.ConwayEra + -> Api.ShelleyBasedEra Exp.ConwayEra + -> m (Ledger.Tx (Exp.LedgerEra Exp.ConwayEra)) + exampleTransactionExperimentalWay era sbe = do + txBodyContent <- exampleTxBodyContent Api.AsConwayEra sbe + signingKey <- exampleSigningKey + + unsignedTx <- H.evalEither $ Exp.makeUnsignedTx era txBodyContent + let witness = Exp.makeKeyWitness era unsignedTx (Api.WitnessPaymentKey signingKey) + + let bootstrapWitnesses = [] + keyWitnesses = [witness] + + let signedTx :: Ledger.Tx (Exp.LedgerEra Exp.ConwayEra) = Exp.signTx era bootstrapWitnesses keyWitnesses unsignedTx + return signedTx diff --git a/cardano-api/test/cardano-api-test/Test/Cardano/Api/GovAnchorValidation.hs b/cardano-api/test/cardano-api-test/Test/Cardano/Api/GovAnchorValidation.hs new file mode 100644 index 0000000000..0a9d006b93 --- /dev/null +++ b/cardano-api/test/cardano-api-test/Test/Cardano/Api/GovAnchorValidation.hs @@ -0,0 +1,112 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Test.Cardano.Api.GovAnchorValidation + ( tests + ) +where + +import Cardano.Api (CIP108 (..), CIP119 (..), File (File), FileDirection (In), FileError, + readByteStringFile) +import Cardano.Api.DRepMetadata (DRepMetadata (..)) +import Cardano.Api.Governance.Metadata.Validation (validateGovActionAnchorData) + +import Data.ByteString (ByteString) +import Data.Monoid (Any) + +import Hedgehog (Property, (===)) +import qualified Hedgehog as H +import Hedgehog.Extras (propertyOnce) +import Test.Tasty (TestTree, testGroup) +import Test.Tasty.Hedgehog (testProperty) + +-- | Tests in this module can be run by themselves by writing: +-- ```bash +-- cabal test cardano-api-test --test-options="--pattern=Test.Cardano.Api.GovAnchorValidation" +-- ``` +tests :: TestTree +tests = + testGroup + "Test.Cardano.Api.GovAnchorValidation" + [ testProperty + "Positive test for DRep registration JSON schema" + prop_positive_drep_registration_json + , testProperty + "Missing 'givenName' test for DRep registration JSON schema" + prop_missing_given_name_drep_registration_json + , testProperty + "Given name too long test for DRep registration JSON schema" + prop_given_name_too_long_drep_registration_json + , testProperty + "Positive test for no confidence JSON schema" + prop_positive_no_confidence_json + , testProperty + "Positive test for treasury withdrawal JSON schema" + prop_positive_treasury_withdrawal_json + , testProperty + "Title name too long test for treasury withdrawal JSON schema" + prop_title_name_too_long_treasury_withdrawal_json + ] + +prop_positive_drep_registration_json :: Property +prop_positive_drep_registration_json = propertyOnce $ do + (eitherValue :: Either (FileError Any) ByteString) <- + readByteStringFile + ( File "test/cardano-api-test/files/input/gov-anchor-data/valid-drep-metadata.jsonld" + :: File DRepMetadata In + ) + value <- H.evalEither eitherValue + validateGovActionAnchorData DrepRegistrationMetadata value === Right () + +prop_missing_given_name_drep_registration_json :: Property +prop_missing_given_name_drep_registration_json = propertyOnce $ do + (eitherValue :: Either (FileError Any) ByteString) <- + readByteStringFile + ( File "test/cardano-api-test/files/input/gov-anchor-data/invalid-drep-metadata.jsonld" + :: File DRepMetadata In + ) + value <- H.evalEither eitherValue + validateGovActionAnchorData DrepRegistrationMetadata value + === Left "Error in $.body: key \"givenName\" not found" + +prop_given_name_too_long_drep_registration_json :: Property +prop_given_name_too_long_drep_registration_json = propertyOnce $ do + (eitherValue :: Either (FileError Any) ByteString) <- + readByteStringFile + ( File "test/cardano-api-test/files/input/gov-anchor-data/too-long-given-name-drep-metadata.jsonld" + :: File DRepMetadata In + ) + value <- H.evalEither eitherValue + validateGovActionAnchorData DrepRegistrationMetadata value + === Left "Error in $.body: key \"givenName\" exceeds maximum length of 80 characters. Got length: 90" + +prop_positive_no_confidence_json :: Property +prop_positive_no_confidence_json = propertyOnce $ do + (eitherValue :: Either (FileError Any) ByteString) <- + readByteStringFile + ( File "test/cardano-api-test/files/input/gov-anchor-data/no-confidence.jsonld" + :: File DRepMetadata In + ) + value <- H.evalEither eitherValue + validateGovActionAnchorData BaseGovActionMetadata value === Right () + +prop_positive_treasury_withdrawal_json :: Property +prop_positive_treasury_withdrawal_json = propertyOnce $ do + (eitherValue :: Either (FileError Any) ByteString) <- + readByteStringFile + ( File "test/cardano-api-test/files/input/gov-anchor-data/threasury-withdrawal.jsonld" + :: File DRepMetadata In + ) + value <- H.evalEither eitherValue + validateGovActionAnchorData BaseGovActionMetadata value === Right () + +prop_title_name_too_long_treasury_withdrawal_json :: Property +prop_title_name_too_long_treasury_withdrawal_json = propertyOnce $ do + (eitherValue :: Either (FileError Any) ByteString) <- + readByteStringFile + ( File "test/cardano-api-test/files/input/gov-anchor-data/too-long-title-treasury-withdraw.jsonld" + :: File DRepMetadata In + ) + value <- H.evalEither eitherValue + validateGovActionAnchorData BaseGovActionMetadata value + === Left "Error in $.body: key \"title\" exceeds maximum length of 80 characters. Got length: 112" diff --git a/cardano-api/test/cardano-api-test/cardano-api-test.hs b/cardano-api/test/cardano-api-test/cardano-api-test.hs index 3b977fb1c0..a386d624c0 100644 --- a/cardano-api/test/cardano-api-test/cardano-api-test.hs +++ b/cardano-api/test/cardano-api-test/cardano-api-test.hs @@ -14,6 +14,7 @@ import qualified Test.Cardano.Api.Envelope import qualified Test.Cardano.Api.EpochLeadership import qualified Test.Cardano.Api.Eras import qualified Test.Cardano.Api.Genesis +import qualified Test.Cardano.Api.GovAnchorValidation import qualified Test.Cardano.Api.IO import qualified Test.Cardano.Api.Json import qualified Test.Cardano.Api.KeysByron @@ -45,6 +46,7 @@ tests = , Test.Cardano.Api.Bech32.tests , Test.Cardano.Api.CBOR.tests , Test.Cardano.Api.Crypto.tests + , Test.Cardano.Api.GovAnchorValidation.tests , Test.Cardano.Api.Envelope.tests , Test.Cardano.Api.EpochLeadership.tests , Test.Cardano.Api.Eras.tests diff --git a/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/invalid-drep-metadata.jsonld b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/invalid-drep-metadata.jsonld new file mode 100644 index 0000000000..2a5c7fec43 --- /dev/null +++ b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/invalid-drep-metadata.jsonld @@ -0,0 +1,26 @@ +{ + "hashAlgorithm": "blake2b-256", + "body": { + "paymentAddress": "addr1q86dnpkva4mm859c8ur7tjxn57zgsu6vg8pdetkdve3fsacnq7twy06u2ev5759vutpjgzfryx0ud8hzedhzerava35qwh3x34", + "image": { + "@type": "ImageObject", + "contentUrl": "https://avatars.githubusercontent.com/u/44342099?v=4", + "sha256": "2a21e4f7b20c8c72f573707b068fb8fc6d8c64d5035c4e18ecae287947fe2b2e" + }, + "objectives": "Buy myself an island.", + "motivations": "I really would like to own an island.", + "qualifications": "I have my 100m swimming badge, so I would be qualified to be able to swim around island.", + "references": [ + { + "@type": "Other", + "label": "A cool island for Ryan", + "uri": "https://www.google.com/maps/place/World's+only+5th+order+recursive+island/@62.6511465,-97.7946829,15.75z/data=!4m14!1m7!3m6!1s0x5216a167810cee39:0x11431abdfe4c7421!2sWorld's+only+5th+order+recursive+island!8m2!3d62.651114!4d-97.7872244!16s%2Fg%2F11spwk2b6n!3m5!1s0x5216a167810cee39:0x11431abdfe4c7421!8m2!3d62.651114!4d-97.7872244!16s%2Fg%2F11spwk2b6n?authuser=0&entry=ttu" + }, + { + "@type": "Link", + "label": "Ryan's Twitter", + "uri": "https://twitter.com/Ryun1_" + } + ] + } +} diff --git a/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/no-confidence.jsonld b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/no-confidence.jsonld new file mode 100644 index 0000000000..2accca3fd4 --- /dev/null +++ b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/no-confidence.jsonld @@ -0,0 +1,76 @@ +{ + "@context": { + "@language": "en-us", + "CIP100": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", + "CIP108": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0108/README.md#", + "hashAlgorithm": "CIP100:hashAlgorithm", + "body": { + "@id": "CIP108:body", + "@context": { + "references": { + "@id": "CIP108:references", + "@container": "@set", + "@context": { + "GovernanceMetadata": "CIP100:GovernanceMetadataReference", + "Other": "CIP100:OtherReference", + "label": "CIP100:reference-label", + "uri": "CIP100:reference-uri", + "referenceHash": { + "@id": "CIP108:referenceHash", + "@context": { + "hashDigest": "CIP108:hashDigest", + "hashAlgorithm": "CIP100:hashAlgorithm" + } + } + } + }, + "title": "CIP108:title", + "abstract": "CIP108:abstract", + "motivation": "CIP108:motivation", + "rationale": "CIP108:rationale" + } + }, + "authors": { + "@id": "CIP100:authors", + "@container": "@set", + "@context": { + "name": "http://xmlns.com/foaf/0.1/name", + "witness": { + "@id": "CIP100:witness", + "@context": { + "witnessAlgorithm": "CIP100:witnessAlgorithm", + "publicKey": "CIP100:publicKey", + "signature": "CIP100:signature" + } + } + } + } + }, + "hashAlgorithm": "blake2b-256", + "body": { + "title": "We must remove the ineffective Constitutional Committee", + "abstract": "The current constitutional committee have not voted for the last 100 epochs, causing an inability to pass any proposals. This is a waste of resources and must be removed.", + "motivation": "In order for governance to work an __active__ constitutional committee is required, without them the system grinds to a halt and important governance actions cannot be passed.", + "rationale": "By moving into a state of no confidence, **we** the DReps are able to vote in a new constitutional committee.", + "references": [ + { + "@type": "Other", + "label": "A governance action that has been unable to pass", + "uri": "https://raw.githubusercontent.com/cardano-foundation/CIPs/blob/master/CIP-0108/examples/treasury-withdrawal.jsonld", + "referenceHash": { + "hashDigest": "70e79c1f12ff3c8c955bc2178a542b5994a21be163dd7655af2c5308d2643323", + "hashAlgorithm": "blake2b-256" + } + } + ] + }, + "authors": [ + { + "witness": { + "witnessAlgorithm": "CIP-0008", + "publicKey": "7ea09a34aebb13c9841c71397b1cabfec5ddf950405293dee496cac2f437480a", + "signature": "84582aa201276761646472657373581d610fdc780023d8be7c9ff3a6bdc0d8d3b263bd0cc12448c40948efbf42a166686173686564f458204a7ecc544559df67ece3f7f90f76c4e3e7e329a274c79a06dcfbf28351db600e5840a5dc881ddabdec69e0e4dabdd43a922ef474f7be1029facdbb9106429e17ec61deda22f2778eda21005127f0c6d10f8a4b0210b8177d03d2ae4618d2423d0807" + } + } + ] +} \ No newline at end of file diff --git a/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/threasury-withdrawal.jsonld b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/threasury-withdrawal.jsonld new file mode 100644 index 0000000000..1537b2dd99 --- /dev/null +++ b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/threasury-withdrawal.jsonld @@ -0,0 +1,73 @@ +{ + "@context": { + "@language": "en-us", + "CIP100": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", + "CIP108": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0108/README.md#", + "hashAlgorithm": "CIP100:hashAlgorithm", + "body": { + "@id": "CIP108:body", + "@context": { + "references": { + "@id": "CIP108:references", + "@container": "@set", + "@context": { + "GovernanceMetadata": "CIP100:GovernanceMetadataReference", + "Other": "CIP100:OtherReference", + "label": "CIP100:reference-label", + "uri": "CIP100:reference-uri", + "referenceHash": { + "@id": "CIP108:referenceHash", + "@context": { + "hashDigest": "CIP108:hashDigest", + "hashAlgorithm": "CIP100:hashAlgorithm" + } + } + } + }, + "title": "CIP108:title", + "abstract": "CIP108:abstract", + "motivation": "CIP108:motivation", + "rationale": "CIP108:rationale" + } + }, + "authors": { + "@id": "CIP100:authors", + "@container": "@set", + "@context": { + "name": "http://xmlns.com/foaf/0.1/name", + "witness": { + "@id": "CIP100:witness", + "@context": { + "witnessAlgorithm": "CIP100:witnessAlgorithm", + "publicKey": "CIP100:publicKey", + "signature": "CIP100:signature" + } + } + } + } + }, + "hashAlgorithm": "blake2b-256", + "body": { + "title": "Buy Ryan a island", + "abstract": "Withdraw 200000000000 ADA from the treasury so Ryan can buy an island.", + "motivation": "The current problem is that Ryan does not have an island, but he would really like an island.", + "rationale": "With these funds from the treasury will be sold for **cold hard cash**, this cash can then be used to purchase an island for Ryan. An example of this island is provided in the references.", + "references": [ + { + "@type": "Other", + "label": "A cool island for Ryan", + "uri": "https://www.google.com/maps/place/World's+only+5th+order+recursive+island/@62.6511465,-97.7946829,15.75z/data=!4m14!1m7!3m6!1s0x5216a167810cee39:0x11431abdfe4c7421!2sWorld's+only+5th+order+recursive+island!8m2!3d62.651114!4d-97.7872244!16s%2Fg%2F11spwk2b6n!3m5!1s0x5216a167810cee39:0x11431abdfe4c7421!8m2!3d62.651114!4d-97.7872244!16s%2Fg%2F11spwk2b6n?authuser=0&entry=ttu" + } + ] + }, + "authors": [ + { + "name": "Ryan Williams", + "witness": { + "witnessAlgorithm": "ed25519", + "publicKey": "7ea09a34aebb13c9841c71397b1cabfec5ddf950405293dee496cac2f437480a", + "signature": "a476985b4cc0d457f247797611799a6f6a80fc8cb7ec9dcb5a8223888d0618e30de165f3d869c4a0d9107d8a5b612ad7c5e42441907f5b91796f0d7187d64a01" + } + } + ] + } \ No newline at end of file diff --git a/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-given-name-drep-metadata.jsonld b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-given-name-drep-metadata.jsonld new file mode 100644 index 0000000000..3598b9f34a --- /dev/null +++ b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-given-name-drep-metadata.jsonld @@ -0,0 +1,17 @@ +{ + "@context": { + "CIP100": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", + "CIP119": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0119/README.md#", + "hashAlgorithm": "CIP100:hashAlgorithm", + "body": { + "@id": "CIP119:body", + "@context": { + "givenName": "CIP119:givenName" + } + } + }, + "hashAlgorithm": "blake2b-256", + "body": { + "givenName": "This name is way too long for it to fit in this field, so it should be rejected by the API" + } +} \ No newline at end of file diff --git a/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-title-treasury-withdraw.jsonld b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-title-treasury-withdraw.jsonld new file mode 100644 index 0000000000..46e49bf3c5 --- /dev/null +++ b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/too-long-title-treasury-withdraw.jsonld @@ -0,0 +1,66 @@ +{ + "@context": { + "@language": "en-us", + "CIP100": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", + "CIP108": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0108/README.md#", + "hashAlgorithm": "CIP100:hashAlgorithm", + "body": { + "@id": "CIP108:body", + "@context": { + "references": { + "@id": "CIP108:references", + "@container": "@set", + "@context": { + "GovernanceMetadata": "CIP100:GovernanceMetadataReference", + "Other": "CIP100:OtherReference", + "label": "CIP100:reference-label", + "uri": "CIP100:reference-uri", + "referenceHash": { + "@id": "CIP108:referenceHash", + "@context": { + "hashDigest": "CIP108:hashDigest", + "hashAlgorithm": "CIP100:hashAlgorithm" + } + } + } + }, + "title": "CIP108:title", + "abstract": "CIP108:abstract", + "motivation": "CIP108:motivation", + "rationale": "CIP108:rationale" + } + }, + "authors": { + "@id": "CIP100:authors", + "@container": "@set", + "@context": { + "name": "http://xmlns.com/foaf/0.1/name", + "witness": { + "@id": "CIP100:witness", + "@context": { + "witnessAlgorithm": "CIP100:witnessAlgorithm", + "publicKey": "CIP100:publicKey", + "signature": "CIP100:signature" + } + } + } + } + }, + "hashAlgorithm": "blake2b-256", + "body": { + "title": "Buy Ryan an island that is sunny and full of palm trees and has a lot of sand and is quiet yet not too far away.", + "abstract": "Withdraw 200000000000 ADA from the treasury so Ryan can buy an island.", + "motivation": "The current problem is that Ryan does not have an island, but he would really like an island.", + "rationale": "With these funds from the treasury will be sold for **cold hard cash**, this cash can then be used to purchase an island for Ryan. An example of this island is provided in the references." + }, + "authors": [ + { + "name": "Ryan Williams", + "witness": { + "witnessAlgorithm": "ed25519", + "publicKey": "7ea09a34aebb13c9841c71397b1cabfec5ddf950405293dee496cac2f437480a", + "signature": "a476985b4cc0d457f247797611799a6f6a80fc8cb7ec9dcb5a8223888d0618e30de165f3d869c4a0d9107d8a5b612ad7c5e42441907f5b91796f0d7187d64a01" + } + } + ] + } \ No newline at end of file diff --git a/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/valid-drep-metadata.jsonld b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/valid-drep-metadata.jsonld new file mode 100644 index 0000000000..c1b312d419 --- /dev/null +++ b/cardano-api/test/cardano-api-test/files/input/gov-anchor-data/valid-drep-metadata.jsonld @@ -0,0 +1,58 @@ +{ + "@context": { + "CIP100": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", + "CIP119": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0119/README.md#", + "hashAlgorithm": "CIP100:hashAlgorithm", + "body": { + "@id": "CIP119:body", + "@context": { + "references": { + "@id": "CIP119:references", + "@container": "@set", + "@context": { + "GovernanceMetadata": "CIP100:GovernanceMetadataReference", + "Other": "CIP100:OtherReference", + "label": "CIP100:reference-label", + "uri": "CIP100:reference-uri" + } + }, + "paymentAddress": "CIP119:paymentAddress", + "givenName": "CIP119:givenName", + "image": { + "@id": "CIP119:image", + "@context": { + "ImageObject": "https://schema.org/ImageObject" + } + }, + "objectives": "CIP119:objectives", + "motivations": "CIP119:motivations", + "qualifications": "CIP119:qualifications" + } + } + }, + "hashAlgorithm": "blake2b-256", + "body": { + "paymentAddress": "addr1q86dnpkva4mm859c8ur7tjxn57zgsu6vg8pdetkdve3fsacnq7twy06u2ev5759vutpjgzfryx0ud8hzedhzerava35qwh3x34", + "givenName": "Ryan Williams", + "image": { + "@type": "ImageObject", + "contentUrl": "https://avatars.githubusercontent.com/u/44342099?v=4", + "sha256": "2a21e4f7b20c8c72f573707b068fb8fc6d8c64d5035c4e18ecae287947fe2b2e" + }, + "objectives": "Buy myself an island.", + "motivations": "I really would like to own an island.", + "qualifications": "I have my 100m swimming badge, so I would be qualified to be able to swim around island.", + "references": [ + { + "@type": "Other", + "label": "A cool island for Ryan", + "uri": "https://www.google.com/maps/place/World's+only+5th+order+recursive+island/@62.6511465,-97.7946829,15.75z/data=!4m14!1m7!3m6!1s0x5216a167810cee39:0x11431abdfe4c7421!2sWorld's+only+5th+order+recursive+island!8m2!3d62.651114!4d-97.7872244!16s%2Fg%2F11spwk2b6n!3m5!1s0x5216a167810cee39:0x11431abdfe4c7421!8m2!3d62.651114!4d-97.7872244!16s%2Fg%2F11spwk2b6n?authuser=0&entry=ttu" + }, + { + "@type": "Link", + "label": "Ryan's Twitter", + "uri": "https://twitter.com/Ryun1_" + } + ] + } +} \ No newline at end of file