Skip to main content

Overview

The Intent contract (identipay::intent) provides a simple but critical function: verifying that the buyer signed the canonicalized intent hash using Ed25519. This signature provides semantic binding of the buyer’s intent to the transaction execution, as described in the whitepaper section 5.3. Unlike traditional credit card payments where merchants can initiate charges, identiPay requires explicit buyer authorization via cryptographic signature.

Source Code

Location: contracts/sources/intent.move:5

How It Works

1

Proposal Creation

Merchant creates a commerce proposal (amount, items, constraints) and sends to buyer’s wallet.
2

Canonicalization

Buyer’s wallet canonicalizes the proposal JSON-LD using URDNA2015 to ensure consistent byte representation.
3

Hashing

Wallet computes intent_hash = SHA3-256(canonicalized_proposal).
4

User Approval

User reviews the proposal in the wallet UI and approves.
5

Signature

Wallet signs the intent hash using the user’s Ed25519 private key: signature = Sign(k_buyer, intent_hash).
6

Settlement

Settlement contract calls verify_intent_signature() to ensure the signature is valid before executing payment.

Data Structures

This module has no custom data structures — it only performs signature verification.

Public Functions

verify_intent_signature

Verify that a signature is valid for a given intent hash and public key. Aborts if invalid. Function Signature:
public(package) fun verify_intent_signature(
    intent_sig: &vector<u8>,
    intent_hash: &vector<u8>,
    public_key: &vector<u8>,
)
intent_sig
&vector<u8>
required
Ed25519 signature (exactly 64 bytes)
intent_hash
&vector<u8>
required
SHA3-256 hash of the canonicalized proposal (32 bytes)
public_key
&vector<u8>
required
Buyer’s Ed25519 public key (exactly 32 bytes)
Errors:
  • EInvalidSignatureLength (1): Signature is not 64 bytes
  • EInvalidPublicKeyLength (2): Public key is not 32 bytes
  • EEmptyIntentHash (3): Intent hash is empty
  • EInvalidSignature (0): Signature verification failed
Visibility: public(package) — only callable by other modules in the identipay package (primarily settlement). Location: intent.move:26-37

Usage Example

import { canonicalize } from 'json-canonicalize';
import { sha3_256 } from '@noble/hashes/sha3';
import { ed25519 } from '@noble/curves/ed25519';

// Merchant's proposal
const proposal = {
  '@context': 'https://schema.org',
  '@type': 'Invoice',
  merchant: 'did:identipay:shop.example.com:merchant123',
  amount: '100.00',
  currency: 'USDC',
  items: [
    {
      '@type': 'Product',
      name: 'Coffee',
      price: '5.00',
    },
  ],
  expiry: '2026-03-09T12:00:00Z',
  constraints: {
    minAge: 18,
  },
};

// 1. Canonicalize using URDNA2015 (for JSON-LD)
const canonicalized = canonicalize(proposal);

// 2. Hash with SHA3-256
const intentHash = sha3_256(canonicalized);

// 3. Display to user for approval
showApprovalDialog(proposal);

// 4. User approves, sign with Ed25519
const signature = ed25519.sign(intentHash, buyerPrivateKey);

// 5. Submit to settlement
await executeSettlement({
  intentHash: Array.from(intentHash),
  intentSig: Array.from(signature),
  buyerPubkey: Array.from(ed25519.getPublicKey(buyerPrivateKey)),
  // ... other params
});

Intent Hash Computation

The intent hash is the SHA3-256 hash of the URDNA2015-canonicalized JSON-LD proposal.

Why URDNA2015?

JSON has many equivalent representations:
{"amount": 100, "merchant": "0x123"}
{"merchant":"0x123","amount":100}
{ "amount" : 100 , "merchant" : "0x123" }
All are semantically identical but have different byte representations. Without canonicalization, the hash would differ. URDNA2015 (RDF Dataset Normalization) ensures:
  • Consistent key ordering
  • Consistent whitespace
  • Consistent Unicode normalization
  • Canonical representation of JSON-LD semantics

Why SHA3-256?

SHA3-256 (Keccak) is used instead of SHA2-256 because:
  • Different construction: Sponge construction vs. Merkle-Damgård
  • Length extension resistance: SHA3 is inherently resistant
  • Modern standard: NIST standardized in 2015
  • Sui native: Sui Move has built-in SHA3 support

Security Properties

Once a buyer signs an intent, they cannot deny having authorized the payment. The signature is cryptographic proof of authorization.
The signature binds to the semantic content of the proposal (amount, items, merchant), not just a generic “approve” message. This prevents bait-and-switch attacks.
The settlement contract tracks executed intent hashes to prevent replay. Even with a valid signature, the same intent cannot be executed twice.
Proposals include expiry timestamps. The settlement contract rejects expired proposals even if the signature is valid.

Attack Prevention

Bait-and-Switch Attack

Attack: Merchant shows user a proposal for 10,butsubmitsadifferentproposalfor10, but submits a different proposal for 1000. Prevention: User’s wallet computes the hash of the displayed proposal and signs it. The on-chain signature verification ensures only the exact proposal that was signed can execute.

Replay Attack

Attack: Merchant captures a valid signature and submits the same intent multiple times. Prevention: Settlement contract maintains a table of executed intent hashes (SettlementState.executed_intents). Second execution aborts with EIntentAlreadyExecuted.

Phishing Attack

Attack: Attacker tricks user into signing a malicious proposal. Prevention: Wallet UI must display proposal details clearly before requesting signature. Users should verify merchant identity via Trust Registry.

Man-in-the-Middle Attack

Attack: Attacker intercepts proposal and modifies it before user signs. Prevention: Proposals should be transmitted over secure channels (HTTPS, WebSocket over TLS). Merchant DID provides authentication.

Comparison to Traditional Payments

FeatureCredit CardsidentiPay Intents
AuthorizationMerchant-initiated (pull)Buyer-initiated (push)
BindingGeneric card numberCryptographic signature on specific proposal
Replay ProtectionLimited (CVV, expiry)Cryptographic (hash tracking)
Non-RepudiationWeak (can dispute)Strong (unforgeable signature)
PrivacyReusable credentialsOne-time stealth addresses
Fraud RiskHigh (credential theft)Low (no reusable credentials)

Edge Cases

Aborts with EEmptyIntentHash. This prevents accidental signing of null data.
If the signature was created with a different private key than the provided public key, verification fails with EInvalidSignature.
If the intent hash is modified after signing, verification fails with EInvalidSignature.
Even a single bit flip in the signature causes verification to fail with EInvalidSignature.

Implementation Notes

Ed25519 Choice

identiPay uses Ed25519 (not ECDSA secp256k1) because:
  • Deterministic: No nonce generation, eliminates nonce-reuse attacks
  • Fast: ~2x faster than ECDSA for signing
  • Small signatures: 64 bytes (vs. 65-73 for ECDSA)
  • Sui native: Sui Move has built-in sui::ed25519::ed25519_verify()

Sui Native Verification

The contract uses Sui’s native ed25519_verify() function:
use sui::ed25519;

let valid = ed25519::ed25519_verify(
    signature,    // 64 bytes
    public_key,   // 32 bytes
    message,      // arbitrary length
);
This is implemented in Sui’s Move VM as a native function, making it very efficient (constant gas cost, fast execution).

Best Practices

Wallet UIs must display the full proposal details (amount, merchant name, items) before requesting the user’s signature. Never auto-sign.
Before showing the approval dialog, verify the merchant is registered in the Trust Registry and active.
Validate that proposal.expiry > current_time before signing. Don’t sign expired proposals.
Store Ed25519 private keys in secure enclaves (iOS Secure Enclave, Android Keystore, hardware wallets).

Settlement

Calls verify_intent_signature during execution

Trust Registry

Verify merchant legitimacy before signing

Build docs developers (and LLMs) love