From 9f446aff2646dd8b024ff5c20ad4ed67e6e10d8f Mon Sep 17 00:00:00 2001 From: KtorZ Date: Thu, 11 Feb 2021 17:26:47 +0100 Subject: [PATCH] add fingerprint to all asset models next to the policy_id and asset_name Motivation, design and rationale explained in: https://github.com/cardano-foundation/CIPs/pull/64 --- lib/core/cardano-wallet-core.cabal | 1 + lib/core/src/Cardano/Wallet/Api/Types.hs | 7 ++ .../Wallet/Primitive/Types/TokenPolicy.hs | 58 ++++++++++++++ .../data/Cardano/Wallet/Api/ApiAsset.json | 55 +++++++++++++ .../test/unit/Cardano/Wallet/Api/TypesSpec.hs | 1 + .../Wallet/Primitive/Types/TokenPolicySpec.hs | 79 +++++++++++++++++++ specifications/api/swagger.yaml | 16 ++++ 7 files changed, 217 insertions(+) create mode 100644 lib/core/test/data/Cardano/Wallet/Api/ApiAsset.json create mode 100644 lib/core/test/unit/Cardano/Wallet/Primitive/Types/TokenPolicySpec.hs diff --git a/lib/core/cardano-wallet-core.cabal b/lib/core/cardano-wallet-core.cabal index 9aee056e1d5..f6120d1411d 100644 --- a/lib/core/cardano-wallet-core.cabal +++ b/lib/core/cardano-wallet-core.cabal @@ -357,6 +357,7 @@ test-suite unit Cardano.Wallet.Primitive.Types.TokenBundleSpec Cardano.Wallet.Primitive.Types.TokenMapSpec Cardano.Wallet.Primitive.Types.TokenMapSpec.TypeErrorSpec + Cardano.Wallet.Primitive.Types.TokenPolicySpec Cardano.Wallet.Primitive.Types.TokenQuantitySpec Cardano.Wallet.Primitive.Types.UTxOIndexSpec Cardano.Wallet.Primitive.Types.UTxOIndex.TypeErrorSpec diff --git a/lib/core/src/Cardano/Wallet/Api/Types.hs b/lib/core/src/Cardano/Wallet/Api/Types.hs index 4191e5646d5..e26e1d23f90 100644 --- a/lib/core/src/Cardano/Wallet/Api/Types.hs +++ b/lib/core/src/Cardano/Wallet/Api/Types.hs @@ -449,6 +449,7 @@ newtype ApiMaintenanceAction = ApiMaintenanceAction data ApiAsset = ApiAsset { policyId :: ApiT W.TokenPolicyId , assetName :: ApiT W.TokenName + , fingerprint :: ApiT W.TokenFingerprint , metadata :: Maybe (ApiT W.AssetMetadata) } deriving (Eq, Generic, Ord, Show) deriving anyclass NFData @@ -457,6 +458,7 @@ toApiAsset :: Maybe W.AssetMetadata -> W.AssetId -> ApiAsset toApiAsset metadata_ (W.AssetId policyId_ assetName_) = ApiAsset { policyId = ApiT policyId_ , assetName = ApiT assetName_ + , fingerprint = ApiT $ W.mkTokenFingerprint policyId_ assetName_ , metadata = ApiT <$> metadata_ } @@ -1277,6 +1279,11 @@ instance FromJSON (ApiT W.TokenName) where instance ToJSON (ApiT W.TokenName) where toJSON = toJSON . hexText . W.unTokenName . getApiT +instance FromJSON (ApiT W.TokenFingerprint) where + parseJSON = fromTextJSON "TokenFingerprint" +instance ToJSON (ApiT W.TokenFingerprint) where + toJSON = toTextJSON + instance FromJSON (ApiT W.AssetMetadata) where parseJSON = fmap ApiT . genericParseJSON defaultRecordTypeOptions instance ToJSON (ApiT W.AssetMetadata) where diff --git a/lib/core/src/Cardano/Wallet/Primitive/Types/TokenPolicy.hs b/lib/core/src/Cardano/Wallet/Primitive/Types/TokenPolicy.hs index 210305ed7e7..ca57d9ea710 100644 --- a/lib/core/src/Cardano/Wallet/Primitive/Types/TokenPolicy.hs +++ b/lib/core/src/Cardano/Wallet/Primitive/Types/TokenPolicy.hs @@ -3,6 +3,8 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DerivingVia #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TypeApplications #-} module Cardano.Wallet.Primitive.Types.TokenPolicy ( @@ -15,6 +17,10 @@ module Cardano.Wallet.Primitive.Types.TokenPolicy , nullTokenName , maxLengthTokenName + -- * Token Fingerprints + , TokenFingerprint (..) + , mkTokenFingerprint + -- * Token Metadata , AssetMetadata (..) , AssetLogo (..) @@ -25,18 +31,28 @@ import Prelude import Cardano.Wallet.Primitive.Types.Hash ( Hash (..) ) +import Codec.Binary.Bech32.TH + ( humanReadablePart ) import Control.DeepSeq ( NFData ) import Control.Monad ( (>=>) ) +import Crypto.Hash + ( hash ) +import Crypto.Hash.Algorithms + ( Blake2b_160 ) import Data.Aeson ( FromJSON (..), ToJSON (..) ) import Data.Bifunctor ( first ) +import Data.ByteArray + ( convert ) import Data.ByteArray.Encoding ( Base (Base16), convertFromBase, convertToBase ) import Data.ByteString ( ByteString ) +import Data.Function + ( (&) ) import Data.Hashable ( Hashable ) import Data.Text @@ -52,9 +68,11 @@ import Numeric.Natural import Quiet ( Quiet (..) ) +import qualified Codec.Binary.Bech32 as Bech32 import qualified Data.ByteString as BS import qualified Data.Text.Encoding as T + -- | Token policy identifiers, represented by the hash of the monetary policy -- script. newtype TokenPolicyId = @@ -128,6 +146,46 @@ instance FromText TokenName where . convertFromBase Base16 . T.encodeUtf8 +newtype TokenFingerprint = + UnsafeTokenFingerprint { unTokenFingerprint :: Text } + deriving stock (Eq, Ord, Generic) + deriving (Read, Show) via (Quiet TokenFingerprint) + deriving anyclass Hashable + +instance NFData TokenFingerprint + +-- | Construct a fingerprint from a 'TokenPolicyId' and 'TokenName'. The +-- fingerprint is not necessarily unique, but can be used in user-facing +-- interfaces as a comparison mechanism. +mkTokenFingerprint :: TokenPolicyId -> TokenName -> TokenFingerprint +mkTokenFingerprint (UnsafeTokenPolicyId (Hash p)) (UnsafeTokenName n) + = (p <> n) + & convert . hash @_ @Blake2b_160 + & Bech32.encodeLenient tokenFingerprintHrp . Bech32.dataPartFromBytes + & UnsafeTokenFingerprint + +tokenFingerprintHrp :: Bech32.HumanReadablePart +tokenFingerprintHrp = [humanReadablePart|asset|] + +instance ToText TokenFingerprint where + toText = unTokenFingerprint + +instance FromText TokenFingerprint where + fromText txt = case Bech32.decodeLenient txt of + Left{} -> Left invalidBech32String + Right (hrp, dp) + | hrp /= tokenFingerprintHrp -> Left unrecognizedHrp + | otherwise -> case BS.length <$> Bech32.dataPartToBytes dp of + Just 20 -> Right (UnsafeTokenFingerprint txt) + _ -> Left invalidDatapart + where + invalidBech32String = TextDecodingError + "A 'TokenFingerprint' must be a valid bech32-encoded string." + unrecognizedHrp = TextDecodingError + "Expected 'asset' as a human-readable part, but got something else." + invalidDatapart = TextDecodingError + "Expected a Blake2b-160 digest as data payload, but got something else." + -- | Information about an asset, from a source external to the chain. data AssetMetadata = AssetMetadata { name :: Text diff --git a/lib/core/test/data/Cardano/Wallet/Api/ApiAsset.json b/lib/core/test/data/Cardano/Wallet/Api/ApiAsset.json new file mode 100644 index 00000000000..170e0c88426 --- /dev/null +++ b/lib/core/test/data/Cardano/Wallet/Api/ApiAsset.json @@ -0,0 +1,55 @@ +{ + "seed": 4252581413497557201, + "samples": [ + { + "asset_name": "546f6b656e43", + "fingerprint": "asset1s3kj3fyeq97tfjnyvdd03z0prq74gz444wyux7", + "policy_id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + { + "asset_name": "546f6b656e42", + "fingerprint": "asset1gazx907gch67k7z698zvjnqtk688lprzsj4h3v", + "policy_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "asset_name": "546f6b656e43", + "fingerprint": "asset159ls9ezykdrcczlxnz79x3snxr4vzechj99srs", + "policy_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "asset_name": "546f6b656e42", + "fingerprint": "asset1gazx907gch67k7z698zvjnqtk688lprzsj4h3v", + "policy_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "asset_name": "546f6b656e42", + "fingerprint": "asset1eqa8zxmephvu6t0lypy8vu87esewclnmztltzs", + "policy_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "asset_name": "546f6b656e43", + "fingerprint": "asset1nhxkxttnpua4phszcvqsxjrlm96uqeg9k950tr", + "policy_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "asset_name": "546f6b656e41", + "fingerprint": "asset16pv5x3vyadqs6khds6wjmenanyy5xvjz35c5va", + "policy_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "asset_name": "546f6b656e41", + "fingerprint": "asset1frylrhchs9tepdq6p4s7ynf3txfet6g405kxpv", + "policy_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "asset_name": "546f6b656e44", + "fingerprint": "asset1zzhx6uhk3273w5kegsm2h3hhx9gl5pwy8ncqkr", + "policy_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "asset_name": "546f6b656e44", + "fingerprint": "asset1j3852kq2l32k4dfdlyj272nrdwvaaqpals708e", + "policy_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] +} \ No newline at end of file diff --git a/lib/core/test/unit/Cardano/Wallet/Api/TypesSpec.hs b/lib/core/test/unit/Cardano/Wallet/Api/TypesSpec.hs index 993c76f1aaa..41465db8daf 100644 --- a/lib/core/test/unit/Cardano/Wallet/Api/TypesSpec.hs +++ b/lib/core/test/unit/Cardano/Wallet/Api/TypesSpec.hs @@ -428,6 +428,7 @@ spec = parallel $ do jsonRoundtripAndGolden $ Proxy @ApiTxMetadata jsonRoundtripAndGolden $ Proxy @ApiMaintenanceAction jsonRoundtripAndGolden $ Proxy @ApiMaintenanceActionPostData + jsonRoundtripAndGolden $ Proxy @ApiAsset describe "Textual encoding" $ do describe "Can perform roundtrip textual encoding & decoding" $ do diff --git a/lib/core/test/unit/Cardano/Wallet/Primitive/Types/TokenPolicySpec.hs b/lib/core/test/unit/Cardano/Wallet/Primitive/Types/TokenPolicySpec.hs new file mode 100644 index 00000000000..9c56382e833 --- /dev/null +++ b/lib/core/test/unit/Cardano/Wallet/Primitive/Types/TokenPolicySpec.hs @@ -0,0 +1,79 @@ + +module Cardano.Wallet.Primitive.Types.TokenPolicySpec + ( spec + ) where + +import Prelude + +import Cardano.Wallet.Primitive.Types.Hash + ( Hash (..) ) +import Cardano.Wallet.Primitive.Types.TokenPolicy + ( TokenFingerprint (..) + , TokenName (..) + , TokenPolicyId (..) + , mkTokenFingerprint + ) +import Cardano.Wallet.Unsafe + ( unsafeFromHex ) +import Data.ByteString + ( ByteString ) +import Data.Text + ( Text ) +import Test.Hspec + ( Spec, SpecWith, describe, it, parallel, shouldBe ) + +import qualified Data.Text as T + +spec :: Spec +spec = parallel $ describe "mkAssetFingerprint" $ do + goldenTestCIP14 + "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373" + "" + "asset1rjklcrnsdzqp65wjgrg55sy9723kw09mlgvlc3" + + goldenTestCIP14 + "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc37e" + "" + "asset1nl0puwxmhas8fawxp8nx4e2q3wekg969n2auw3" + + goldenTestCIP14 + "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209" + "" + "asset1uyuxku60yqe57nusqzjx38aan3f2wq6s93f6ea" + + goldenTestCIP14 + "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373" + "504154415445" + "asset13n25uv0yaf5kus35fm2k86cqy60z58d9xmde92" + + goldenTestCIP14 + "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209" + "504154415445" + "asset1hv4p5tv2a837mzqrst04d0dcptdjmluqvdx9k3" + + goldenTestCIP14 + "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209" + "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373" + "asset1aqrdypg669jgazruv5ah07nuyqe0wxjhe2el6f" + + goldenTestCIP14 + "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373" + "1e349c9bdea19fd6c147626a5260bc44b71635f398b67c59881df209" + "asset17jd78wukhtrnmjh3fngzasxm8rck0l2r4hhyyt" + + goldenTestCIP14 + "7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373" + "0000000000000000000000000000000000000000000000000000000000000000" + "asset1pkpwyknlvul7az0xx8czhl60pyel45rpje4z8w" + +goldenTestCIP14 + :: ByteString -- Base16-encoded PolicyId + -> ByteString -- Base16-encoded AssetName + -> Text -- Bech32-encoded TokenFingerprint + -> SpecWith () +goldenTestCIP14 rawPolicyId rawAssetName rawFingerprint = + it ("golden test CIP-0014 - " <> T.unpack rawFingerprint) $ do + let policyId = UnsafeTokenPolicyId $ Hash $ unsafeFromHex rawPolicyId + let assetName = UnsafeTokenName $ unsafeFromHex rawAssetName + let fingerprint = UnsafeTokenFingerprint rawFingerprint + mkTokenFingerprint policyId assetName `shouldBe` fingerprint diff --git a/specifications/api/swagger.yaml b/specifications/api/swagger.yaml index c3e3d40e7e5..688c3d07176 100644 --- a/specifications/api/swagger.yaml +++ b/specifications/api/swagger.yaml @@ -500,6 +500,19 @@ x-assetPolicyId: &assetPolicyId maxLength: 56 example: 65ab82542b0ca20391caaf66a4d4d7897d281f9c136cd3513136945b +x-assetFingerprint: &assetFingerprint + type: string + description: | + A user-facing short fingerprint which combines the `policy_id` and `asset_name` + to allow for an easier human comparison of assets. Note that it is generally + **not okay** to use this fingerprint as a unique identifier for it is not collision + resistant. Yet within the context of a single wallet, it makes for a (rather) + short user-facing comparison mean. + pattern: "^(asset)1[0-9a-z]*$" + maxLength: 44 + minLength: 44 + example: token1rjklcrnsdzqp65wjgrg55sy9723kw09m5z9489 + x-assetMetadataName: &assetMetadataName type: string maxLength: 50 @@ -715,6 +728,7 @@ x-walletAsset: &walletAsset properties: policy_id: *assetPolicyId asset_name: *assetName + fingerprint: *assetFingerprint quantity: *assetQuantity x-walletAssets: &walletAssets @@ -731,6 +745,7 @@ x-assetMint: &assetMint properties: policy_id: *assetPolicyId asset_name: *assetName + fingerprint: *assetFingerprint quantity: type: integer description: | @@ -1780,6 +1795,7 @@ components: properties: policy_id: *assetPolicyId asset_name: *assetName + fingerprint: *assetFingerprint metadata: *assetMetadata ApiWalletMigrationInfo: &ApiWalletMigrationInfo