From 0d7a4ee070ab3155ea4da1c7cb432172aa0df956 Mon Sep 17 00:00:00 2001
From: Technici4n <13494793+Technici4n@users.noreply.github.com>
Date: Tue, 17 Aug 2021 20:08:09 +0200
Subject: [PATCH] Fabric Transfer API: item transfer and fluid-containing
 items. (#1553)

* Add item and "fluid item" APIs

* Rework ContainerItemContext javadoc

* Rework the Inventory wrapper API

* Cleanup inventory wrapper implementation, add < 64 max stack count test, separate tests better

* Fix Inventory wrapper not limiting the stack count correctly (thanks @lilybeevee!)

* Rewrite inventory wrapper, add SingleStackStorage base implementation

* Composters

* SingleStackStorage adjustements

* Bump version

* Move icon to correct location. Closes #1565

* Bump version

* Remove composter implementation (it's broken), slight renames

* Fix SidedInventory extract

* Bump version

* Don't use MAVEN_USERNAME if it's not specified

* Add comparator output, add missing markDirty calls, fix tests

* Bump version

* Add SingleVariantStorage, deprecate SingleFluidStorage, definalize a few things, make sure markDirty() is only called once per successful outer transaction in inventory wrappers

* Add composter support

* Move EmptyFluidView to BlankVariantView, update README and package-info

* Bump version

* Key -> variant

* Add Transaction#openNested(@Nullable TransactionContext)

* Add SingleSlotContainerItemContext

* Bump prerelease version

* Remove useless comment

* Remove ContainerItemContext#getWorld

* Bump prerelease version

* Add StorageUtil#findExtractableContent and ContainerItemContext#withInitial

* Bump prerelease version
---
 build.gradle                                  |   8 +-
 fabric-api-lookup-api-v1/build.gradle         |   2 +-
 .../api/lookup/v1/block/BlockApiLookup.java   |   7 +
 .../api/lookup/v1/item/ItemApiLookup.java     |   8 +
 .../impl/lookup/block/BlockApiLookupImpl.java |   1 +
 .../impl/lookup/item/ItemApiLookupImpl.java   |   6 +
 fabric-transfer-api-v1/README.md              |  22 +-
 fabric-transfer-api-v1/build.gradle           |   2 +-
 .../v1/context/ContainerItemContext.java      | 242 ++++++++++++++++++
 .../api/transfer/v1/fluid/FluidStorage.java   | 104 ++++++++
 .../v1/fluid/base/EmptyItemFluidStorage.java  | 129 ++++++++++
 .../v1/fluid/base/FullItemFluidStorage.java   | 136 ++++++++++
 .../v1/fluid/base/SingleFluidStorage.java     |  13 +-
 .../transfer/v1/item/InventoryStorage.java    |  74 ++++++
 .../api/transfer/v1/item/ItemStorage.java     | 106 ++++++++
 .../api/transfer/v1/item/ItemVariant.java     | 121 +++++++++
 .../v1/item/PlayerInventoryStorage.java       |  79 ++++++
 .../v1/item/base/SingleStackStorage.java      | 164 ++++++++++++
 .../fabric/api/transfer/v1/package-info.java  |  26 +-
 .../api/transfer/v1/storage/Storage.java      |   1 +
 .../api/transfer/v1/storage/StorageUtil.java  |  72 +++++-
 .../v1/storage/base/BlankVariantView.java     |  75 ++++++
 .../v1/storage/base/CombinedStorage.java      |   2 +-
 .../v1/storage/base/SingleVariantStorage.java | 145 +++++++++++
 .../transfer/v1/transaction/Transaction.java  |   8 +
 .../transaction/base/SnapshotParticipant.java |   6 +-
 .../InitialContentsContainerItemContext.java  |  63 +++++
 .../context/PlayerContainerItemContext.java   |  64 +++++
 .../SingleSlotContainerItemContext.java       |  49 ++++
 .../impl/transfer/fluid/CauldronStorage.java  |   3 +-
 .../transfer/fluid/CombinedProvidersImpl.java | 100 ++++++++
 .../transfer/fluid/EmptyBucketStorage.java    |  72 ++++++
 .../transfer/fluid/WaterPotionStorage.java    | 117 +++++++++
 .../impl/transfer/item/ComposterWrapper.java  | 203 +++++++++++++++
 .../impl/transfer/item/CursorSlotWrapper.java |  53 ++++
 .../transfer/item/InventorySlotWrapper.java   |  69 +++++
 .../transfer/item/InventoryStorageImpl.java   | 131 ++++++++++
 .../impl/transfer/item/ItemVariantCache.java  |  26 ++
 .../impl/transfer/item/ItemVariantImpl.java   | 138 ++++++++++
 .../item/PlayerInventoryStorageImpl.java      | 123 +++++++++
 .../item/SidedInventorySlotWrapper.java       |  77 ++++++
 .../item/SidedInventoryStorageImpl.java       |  55 ++++
 .../mixin/transfer/BucketItemAccessor.java    |  29 +++
 .../transfer/DoubleInventoryAccessor.java     |  32 +++
 .../mixin/transfer/DropperBlockMixin.java     |  65 +++++
 .../transfer/HopperBlockEntityAccessor.java   |  36 +++
 .../transfer/HopperBlockEntityMixin.java      |  92 +++++++
 .../fabric/mixin/transfer/ItemMixin.java      |  40 +++
 .../fabric-transfer-api-v1/icon.png           | Bin
 .../fabric-transfer-api-v1.mixins.json        |   8 +-
 .../src/main/resources/fabric.mod.json        |   4 +-
 .../test/transfer/fluid/FluidItemTests.java   | 185 +++++++++++++
 .../transfer/fluid/FluidTransferTest.java     |  28 +-
 .../fabric/test/transfer/fluid/ItemTests.java | 188 ++++++++++++++
 54 files changed, 3564 insertions(+), 45 deletions(-)
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/context/ContainerItemContext.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/EmptyItemFluidStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/FullItemFluidStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/InventoryStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemVariant.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/PlayerInventoryStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/base/SingleStackStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/BlankVariantView.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/SingleVariantStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/InitialContentsContainerItemContext.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/PlayerContainerItemContext.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/SingleSlotContainerItemContext.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CombinedProvidersImpl.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/EmptyBucketStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/WaterPotionStorage.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ComposterWrapper.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/CursorSlotWrapper.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventorySlotWrapper.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventoryStorageImpl.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantCache.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantImpl.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/PlayerInventoryStorageImpl.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventorySlotWrapper.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventoryStorageImpl.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BucketItemAccessor.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DoubleInventoryAccessor.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java
 create mode 100644 fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ItemMixin.java
 rename fabric-transfer-api-v1/src/main/resources/{ => assets}/fabric-transfer-api-v1/icon.png (100%)
 create mode 100644 fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java
 create mode 100644 fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java

diff --git a/build.gradle b/build.gradle
index 03e94ac79..c1ecd0cea 100644
--- a/build.gradle
+++ b/build.gradle
@@ -311,9 +311,11 @@ void setupRepositories(RepositoryHandler repositories) {
 	if (ENV.MAVEN_URL) {
 		repositories.maven {
 			url ENV.MAVEN_URL
-			credentials {
-				username ENV.MAVEN_USERNAME
-				password ENV.MAVEN_PASSWORD
+			if (ENV.MAVEN_USERNAME) {
+				credentials {
+					username ENV.MAVEN_USERNAME
+					password ENV.MAVEN_PASSWORD
+				}
 			}
 		}
 	}
diff --git a/fabric-api-lookup-api-v1/build.gradle b/fabric-api-lookup-api-v1/build.gradle
index cdc889cab..6b1803ad4 100644
--- a/fabric-api-lookup-api-v1/build.gradle
+++ b/fabric-api-lookup-api-v1/build.gradle
@@ -1,5 +1,5 @@
 archivesBaseName = "fabric-api-lookup-api-v1"
-version = getSubprojectVersion(project, "1.2.0")
+version = getSubprojectVersion(project, "1.3.0")
 
 moduleDependencies(project, [
 	'fabric-api-base',
diff --git a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/block/BlockApiLookup.java b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/block/BlockApiLookup.java
index 25c2fe5a5..e16077db6 100644
--- a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/block/BlockApiLookup.java
+++ b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/block/BlockApiLookup.java
@@ -227,6 +227,13 @@ public interface BlockApiLookup<A, C> {
 	 */
 	Class<C> contextClass();
 
+	/**
+	 * Return the provider for the passed block (registered with one of the {@code register} functions), or null if none was registered (yet).
+	 * Queries should go through {@link #find}, only use this to inspect registered providers!
+	 */
+	@Nullable
+	BlockApiProvider<A, C> getProvider(Block block);
+
 	@FunctionalInterface
 	interface BlockApiProvider<A, C> {
 		/**
diff --git a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/item/ItemApiLookup.java b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/item/ItemApiLookup.java
index b1e0418f0..5c29b03c0 100644
--- a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/item/ItemApiLookup.java
+++ b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/api/lookup/v1/item/ItemApiLookup.java
@@ -19,6 +19,7 @@ package net.fabricmc.fabric.api.lookup.v1.item;
 import org.jetbrains.annotations.ApiStatus;
 import org.jetbrains.annotations.Nullable;
 
+import net.minecraft.item.Item;
 import net.minecraft.item.ItemConvertible;
 import net.minecraft.item.ItemStack;
 import net.minecraft.util.Identifier;
@@ -154,6 +155,13 @@ public interface ItemApiLookup<A, C> {
 	 */
 	Class<C> contextClass();
 
+	/**
+	 * Return the provider for the passed item (registered with one of the {@code register} functions), or null if none was registered (yet).
+	 * Queries should go through {@link #find}, only use this to inspect registered providers!
+	 */
+	@Nullable
+	ItemApiProvider<A, C> getProvider(Item item);
+
 	@FunctionalInterface
 	interface ItemApiProvider<A, C> {
 		/**
diff --git a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/block/BlockApiLookupImpl.java b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/block/BlockApiLookupImpl.java
index 1c9627a20..c71406e16 100644
--- a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/block/BlockApiLookupImpl.java
+++ b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/block/BlockApiLookupImpl.java
@@ -184,6 +184,7 @@ public final class BlockApiLookupImpl<A, C> implements BlockApiLookup<A, C> {
 		return contextClass;
 	}
 
+	@Override
 	@Nullable
 	public BlockApiProvider<A, C> getProvider(Block block) {
 		return providerMap.get(block);
diff --git a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/item/ItemApiLookupImpl.java b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/item/ItemApiLookupImpl.java
index dbd7b8397..b4c44701f 100644
--- a/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/item/ItemApiLookupImpl.java
+++ b/fabric-api-lookup-api-v1/src/main/java/net/fabricmc/fabric/impl/lookup/item/ItemApiLookupImpl.java
@@ -133,4 +133,10 @@ public class ItemApiLookupImpl<A, C> implements ItemApiLookup<A, C> {
 	public Class<C> contextClass() {
 		return contextClass;
 	}
+
+	@Override
+	@Nullable
+	public ItemApiProvider<A, C> getProvider(Item item) {
+		return providerMap.get(item);
+	}
 }
diff --git a/fabric-transfer-api-v1/README.md b/fabric-transfer-api-v1/README.md
index 674366490..0dd515ede 100644
--- a/fabric-transfer-api-v1/README.md
+++ b/fabric-transfer-api-v1/README.md
@@ -17,15 +17,15 @@ for example to move resources between two `Storage`s.
 The [`storage/base`](src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base) package provides a few helpers to accelerate
 implementation of `Storage<T>`.
 
+Implementors of inventories with a fixed number of "slots" or "tanks" can use
+[`SingleVariantStorage`](src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/SingleStorage.java),
+and combine them with `CombinedStorage`.
+
 ## Fluid transfer
 A `Storage<FluidVariant>` is any object that can store fluids. It is just a `Storage<T>`, where `T` is
 [`FluidVariant`](src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidVariant.java), the immutable combination of a `Fluid` and additional NBT data.
 Instances can be accessed through the API lookups defined in [`FluidStorage`](src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidStorage.java).
 
-Implementors of fluid inventories with a fixed number of "slots" or "tanks" can use
-[`SingleFluidStorage`](src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/SingleFluidStorage.java),
-and combine them with `CombinedStorage`.
-
 The unit for fluid transfer is 1/81000ths of a bucket, also known as _droplets_.
 [`FluidConstants`](src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidConstants.java) contains a few helpful constants
 to work with droplets.
@@ -34,3 +34,17 @@ Client-side [Fluid variant rendering](src/main/java/net/fabricmc/fabric/api/tran
 ignoring the additional NBT data.
 `Fluid`s that wish to render differently depending on the stored NBT data can register a
 [`FluidVariantRenderHandler`](src/main/java/net/fabricmc/fabric/api/transfer/v1/client/fluid/FluidVariantRenderHandler.java).
+
+## Item transfer
+A `Storage<ItemVariant>` is any object that can store items.
+Instances can be accessed through the API lookup defined in [`ItemStorage`](src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java).
+
+The lookup already provides compatibility with vanilla inventories, however it may sometimes be interesting to use
+[`InventoryStorage`](src/main/java/net/fabricmc/fabric/api/transfer/v1/item/InventoryStorage.java) or
+[`PlayerInventoryStorage`](src/main/java/net/fabricmc/fabric/api/transfer/v1/item/PlayerInventoryStorage.java) when interaction with
+`Inventory`-based APIs is required.
+
+## `ContainerItemContext`
+[`ContainerItemContext`](src/main/java/net/fabricmc/fabric/api/transfer/v1/context/ContainerItemContext.java) is a context designed for `ItemApiLookup` queries
+that allows the returned APIs to interact with the containing inventory.
+Notably, it is used by the `FluidStorage.ITEM` lookup for fluid-containing items.
diff --git a/fabric-transfer-api-v1/build.gradle b/fabric-transfer-api-v1/build.gradle
index ec7125c07..f2d1b7a48 100644
--- a/fabric-transfer-api-v1/build.gradle
+++ b/fabric-transfer-api-v1/build.gradle
@@ -1,5 +1,5 @@
 archivesBaseName = "fabric-transfer-api-v1"
-version = getSubprojectVersion(project, "1.0.0")
+version = getSubprojectVersion(project, "1.1.0-pre.09")
 
 moduleDependencies(project, [
 	'fabric-api-base',
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/context/ContainerItemContext.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/context/ContainerItemContext.java
new file mode 100644
index 000000000..d02130e62
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/context/ContainerItemContext.java
@@ -0,0 +1,242 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.context;
+
+import java.util.List;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.item.ItemStack;
+import net.minecraft.screen.ScreenHandler;
+import net.minecraft.util.Hand;
+
+import net.fabricmc.fabric.api.lookup.v1.item.ItemApiLookup;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.PlayerInventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.impl.transfer.context.InitialContentsContainerItemContext;
+import net.fabricmc.fabric.impl.transfer.context.PlayerContainerItemContext;
+import net.fabricmc.fabric.impl.transfer.context.SingleSlotContainerItemContext;
+
+/**
+ * A context that allows an item-queried {@link Storage} implementation to interact with its containing inventory,
+ * such as a player inventory or an emptying or filling machine.
+ * For example, it is what allows the {@code Storage<FluidVariant>} of a water bucket to replace the full bucket by an empty bucket
+ * on extraction.
+ * Such items that contain resources are often referred to as "container items".
+ *
+ * <p>When an {@linkplain ItemApiLookup item API} requires a {@code ContainerItemContext} as context,
+ * it will generally be suitable to obtain a context instance with {@link #ofPlayerHand} or {@link #ofPlayerCursor},
+ * and then use {@link #find} to query an API instance.
+ *
+ * <p>When water is extracted from the {@code Storage} of a water bucket, this is how it interacts with the context:
+ * <ul>
+ *     <li>The first step is to remove one water bucket item from the current slot,
+ *     that is the slot that contains the water bucket.</li>
+ *     <li>The second step is to try to add one empty bucket item to the current slot, at the same position.</li>
+ *     <li>If that fails, the third step is to add the empty bucket item somewhere else in the inventory.</li>
+ *     <li>The water extraction can only proceed if both step 1, and step 2 or 3, succeed.</li>
+ * </ul>
+ * Before attempting to change the current item, the {@code Storage} implementation must of course check that
+ * the item in the current slot is still a water bucket.
+ *
+ * <p>A {@code ContainerItemContext} allows these operations to be performed, thanks to the following parts:
+ * <ul>
+ *     <li>{@linkplain #getMainSlot The main slot} or current slot of the context, containing the item the API was queried for initially.
+ *     In the example above, this is the slot containing the water bucket, used for steps 1 and 2.</li>
+ *     <li>{@linkplain #insertOverflow An overflow insertion function}, that can be used to insert items into the context's inventory
+ *     when insertion into a specific slot fails. In our example above, this is the function used for step 3.</li>
+ *     <li>The context may also contain additional slots, accessible through {@link #getAdditionalSlots}.</li>
+ * </ul>
+ *
+ * <p>Implementors of item APIs can freely use these methods, but most will generally want to use the following convenience methods instead:
+ * <ul>
+ *     <li>Query which variant is currently in the main slot through {@link #getItemVariant}.
+ *     <b>It is important to check this before any operation, to make sure the item variant hasn't changed since the query.</b></li>
+ *     <li>Query how much of the (non-blank) variant is in the inventory through {@link #getAmount}.</li>
+ *     <li>Extract some items from the main slot with {@link #extract}. In the water bucket example, this can be used for step 1.</li>
+ *     <li>Insert some items, either into the main slot if possible or the rest of the inventory otherwise, with {@link #insert}.
+ *     In the water bucket example, this can be used for steps 2 and 3.</li>
+ *     <li>Exchange some of the current variant with another variant through {@link #exchange}.
+ *     In the water bucket example, this function can be used to combine steps 1, 2 and 3.</li>
+ * </ul>
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public interface ContainerItemContext {
+	/**
+	 * Return a context for the passed player's hand. This is recommended for item use interactions.
+	 */
+	static ContainerItemContext ofPlayerHand(PlayerEntity player, Hand hand) {
+		return new PlayerContainerItemContext(player, hand);
+	}
+
+	/**
+	 * Return a context for the passed player's cursor slot. This is recommended for screen handler click interactions.
+	 */
+	static ContainerItemContext ofPlayerCursor(PlayerEntity player, ScreenHandler screenHandler) {
+		return ofPlayerSlot(player, PlayerInventoryStorage.getCursorStorage(screenHandler));
+	}
+
+	/**
+	 * Return a context for a slot, with the passed player as fallback.
+	 */
+	static ContainerItemContext ofPlayerSlot(PlayerEntity player, SingleSlotStorage<ItemVariant> slot) {
+		return new PlayerContainerItemContext(player, slot);
+	}
+
+	/**
+	 * Return a context for a single slot, with no fallback.
+	 *
+	 * @param slot The main slot of the context.
+	 */
+	static ContainerItemContext ofSingleSlot(SingleSlotStorage<ItemVariant> slot) {
+		return new SingleSlotContainerItemContext(slot);
+	}
+
+	/**
+	 * Return a context that can accept anything, and will accept (and destroy) any overflow items, with some initial content.
+	 * This can typically be used to check if a stack provides an API, or simulate operations on the returned API,
+	 * for example to simulate how much fluid could be extracted from the stack.
+	 *
+	 * <p>Note that the stack can never be mutated by this function: its contents are copied directly.
+	 */
+	static ContainerItemContext withInitial(ItemStack initialContent) {
+		return withInitial(ItemVariant.of(initialContent), initialContent.getCount());
+	}
+
+	/**
+	 * Return a context that can accept anything, and will accept (and destroy) any overflow items, with some initial variant and amount.
+	 * This can typically be used to check if a variant provides an API, or simulate operations on the returned API,
+	 * for example to simulate how much fluid could be extracted from the variant and amount.
+	 */
+	static ContainerItemContext withInitial(ItemVariant initialVariant, long initialAmount) {
+		StoragePreconditions.notBlankNotNegative(initialVariant, initialAmount);
+		return new InitialContentsContainerItemContext(initialVariant, initialAmount);
+	}
+
+	/**
+	 * Try to find an API instance for the passed lookup and return it, or {@code null} if there is none.
+	 * The API is queried for the current variant, if it's not blank.
+	 *
+	 * @see ItemApiLookup#find
+	 */
+	@Nullable
+	default <A> A find(ItemApiLookup<A, ContainerItemContext> lookup) {
+		return getItemVariant().isBlank() ? null : lookup.find(getItemVariant().toStack(), this);
+	}
+
+	/**
+	 * Return the current item variant of this context, that is the variant in the slot of the context.
+	 * If the result is non blank, {@link #getAmount} should be
+	 */
+	default ItemVariant getItemVariant() {
+		return getMainSlot().getResource();
+	}
+
+	/**
+	 * Return the current amount of {@link #getItemVariant()} in the slot of the context.
+	 *
+	 * @throws IllegalStateException If {@linkplain #getItemVariant() the current variant} is blank.
+	 */
+	default long getAmount() {
+		if (getItemVariant().isBlank()) {
+			throw new IllegalStateException("Amount may not be queried when the current item variant is blank.");
+		}
+
+		return getMainSlot().getAmount();
+	}
+
+	/**
+	 * Try to insert some items into this context, prioritizing the main slot over the rest of the inventory.
+	 *
+	 * @see Storage#insert
+	 */
+	default long insert(ItemVariant itemVariant, long maxAmount, TransactionContext transaction) {
+		// Main slot first
+		long mainInserted = getMainSlot().insert(itemVariant, maxAmount, transaction);
+		// Overflow second
+		long overflowInserted = insertOverflow(itemVariant, maxAmount - mainInserted, transaction);
+
+		return mainInserted + overflowInserted;
+	}
+
+	/**
+	 * Try to extract some items from this context's main slot.
+	 *
+	 * @see Storage#extract
+	 */
+	default long extract(ItemVariant itemVariant, long maxAmount, TransactionContext transaction) {
+		return getMainSlot().extract(itemVariant, maxAmount, transaction);
+	}
+
+	/**
+	 * Try to exchange as many items as possible of {@linkplain #getItemVariant() the current variant} with another variant.
+	 * That is, extract the old variant, and insert the same amount of the new variant instead.
+	 *
+	 * @param newVariant The variant of the items after the conversion. May not be blank.
+	 * @param maxAmount The maximum amount of items to convert. May not be negative.
+	 * @param transaction The transaction this operation is part of.
+	 * @return A nonnegative integer not greater than maxAmount: the amount that was transformed.
+	 */
+	default long exchange(ItemVariant newVariant, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(newVariant, maxAmount);
+
+		try (Transaction nested = transaction.openNested()) {
+			long extracted = extract(getItemVariant(), maxAmount, nested);
+
+			if (insert(newVariant, extracted, nested) == extracted) {
+				nested.commit();
+				return extracted;
+			}
+		}
+
+		return 0;
+	}
+
+	/**
+	 * Return the main slot of this context.
+	 */
+	SingleSlotStorage<ItemVariant> getMainSlot();
+
+	/**
+	 * Try to insert items into this context, without prioritizing a specific slot, similar to {@link PlayerInventory#offerOrDrop}.
+	 * This should be used for insertion after insertion into the main slot failed.
+	 * {@link #insert} can be used to insert into the main slot first, then send any overflow through this function.
+	 *
+	 * @see Storage#insert
+	 */
+	long insertOverflow(ItemVariant itemVariant, long maxAmount, TransactionContext transactionContext);
+
+	/**
+	 * Get additional slots that may be available in this context.
+	 * These may or may not include the main slot of this context, as it is not always practical to remove it from the list.
+	 *
+	 * @return An unmodifiable list containing additional slots of this context. If no additional slot is available, the list is empty.
+	 */
+	List<SingleSlotStorage<ItemVariant>> getAdditionalSlots();
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidStorage.java
index aacfe18d2..32b8e022c 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidStorage.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/FluidStorage.java
@@ -17,13 +17,31 @@
 package net.fabricmc.fabric.api.transfer.v1.fluid;
 
 import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
 
+import net.minecraft.fluid.Fluid;
 import net.minecraft.fluid.Fluids;
+import net.minecraft.item.BucketItem;
+import net.minecraft.item.Item;
+import net.minecraft.item.Items;
+import net.minecraft.item.ItemStack;
+import net.minecraft.potion.PotionUtil;
+import net.minecraft.potion.Potions;
 import net.minecraft.util.Identifier;
 import net.minecraft.util.math.Direction;
 
+import net.fabricmc.fabric.api.event.Event;
 import net.fabricmc.fabric.api.lookup.v1.block.BlockApiLookup;
+import net.fabricmc.fabric.api.lookup.v1.item.ItemApiLookup;
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.base.EmptyItemFluidStorage;
+import net.fabricmc.fabric.api.transfer.v1.fluid.base.FullItemFluidStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
 import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.impl.transfer.fluid.EmptyBucketStorage;
+import net.fabricmc.fabric.impl.transfer.fluid.CombinedProvidersImpl;
+import net.fabricmc.fabric.impl.transfer.fluid.WaterPotionStorage;
+import net.fabricmc.fabric.mixin.transfer.BucketItemAccessor;
 
 /**
  * Access to {@link Storage Storage&lt;FluidVariant&gt;} instances.
@@ -49,11 +67,97 @@ public final class FluidStorage {
 	public static final BlockApiLookup<Storage<FluidVariant>, Direction> SIDED =
 			BlockApiLookup.get(new Identifier("fabric:sided_fluid_storage"), Storage.asClass(), Direction.class);
 
+	/**
+	 * Item access to fluid variant storages.
+	 * Querying should happen through {@link ContainerItemContext#find}.
+	 *
+	 * <p>Fluid amounts are always expressed in {@linkplain FluidConstants droplets}.
+	 * By default, Fabric API only registers storage support for buckets that have a 1:1 mapping to their fluid, and for water potions.
+	 *
+	 * <p>{@link #combinedItemApiProvider} and {@link #GENERAL_COMBINED_PROVIDER} should be used for API provider registration
+	 * when multiple mods may want to offer a storage for the same item.
+	 *
+	 * <p>Base implementations are provided: {@link EmptyItemFluidStorage} and {@link FullItemFluidStorage}.
+	 *
+	 * <p>This may be queried both client-side and server-side.
+	 * Returned APIs should behave the same regardless of the logical side.
+	 */
+	public static final ItemApiLookup<Storage<FluidVariant>, ContainerItemContext> ITEM =
+			ItemApiLookup.get(new Identifier("fabric:fluid_storage"), Storage.asClass(), ContainerItemContext.class);
+
+	/**
+	 * Get or create and register a {@link CombinedItemApiProvider} event for the passed item.
+	 * Allows multiple API providers to provide a {@code Storage<FluidVariant>} implementation for the same item.
+	 *
+	 * <p>When the item is queried for an API through {@link #ITEM}, all the providers registered through the event will be invoked.
+	 * All non-null {@code Storage<FluidVariant>} instances returned by the providers will be combined in a single storage,
+	 * that will be the final result of the query, or {@code null} if no storage is offered by the event handlers.
+	 *
+	 * <p>This is appropriate to use when multiple mods could wish to expose the Fluid API for some items,
+	 * for example when dealing with items added by the base Minecraft game such as buckets or empty bottles.
+	 * A typical usage example is a mod adding support for filling empty bottles with a honey fluid:
+	 * Fabric API already registers a storage for empty bottles to allow filling them with water through the event,
+	 * and a mod can register an event handler that will attach a second storage allowing empty bottles to be filled with its honey fluid.
+	 *
+	 * @throws IllegalStateException If an incompatible provider is already registered for the item.
+	 */
+	public static Event<CombinedItemApiProvider> combinedItemApiProvider(Item item) {
+		return CombinedProvidersImpl.getOrCreateItemEvent(item);
+	}
+
+	/**
+	 * Allows multiple API providers to return {@code Storage<FluidVariant>} implementations for some items.
+	 * {@link #combinedItemApiProvider} is per-item while this one is queried for all items, hence the "general" name.
+	 *
+	 * <p>Implementation note: This event is invoked both through an API Lookup fallback, and by the {@code combinedItemApiProvider} events.
+	 * This means that per-item combined providers registered through {@code combinedItemApiProvider} DO NOT prevent these general providers from running,
+	 * however regular providers registered through {@code ItemApiLookup#register...} that return a non-null API instance DO prevent it.
+	 */
+	public static Event<CombinedItemApiProvider> GENERAL_COMBINED_PROVIDER = CombinedProvidersImpl.createEvent(false);
+
+	@FunctionalInterface
+	public interface CombinedItemApiProvider {
+		/**
+		 * Return a {@code Storage<FluidVariant>} if available in the given context, or {@code null} otherwise.
+		 * The current item variant can be {@linkplain ContainerItemContext#getItemVariant() retrieved from the context}.
+		 */
+		@Nullable
+		Storage<FluidVariant> find(ContainerItemContext context);
+	}
+
 	private FluidStorage() {
 	}
 
 	static {
 		// Initialize vanilla cauldron wrappers
 		CauldronFluidContent.getForFluid(Fluids.WATER);
+
+		// Register combined fallback
+		FluidStorage.ITEM.registerFallback((stack, context) -> GENERAL_COMBINED_PROVIDER.invoker().find(context));
+		// Register empty bucket storage
+		combinedItemApiProvider(Items.BUCKET).register(EmptyBucketStorage::new);
+		// Register full bucket storage
+		GENERAL_COMBINED_PROVIDER.register(context -> {
+			if (context.getItemVariant().getItem() instanceof BucketItem bucketItem) {
+				Fluid bucketFluid = ((BucketItemAccessor) bucketItem).fabric_getFluid();
+
+				// Make sure the mapping is bidirectional.
+				if (bucketFluid != null && bucketFluid.getBucketItem() == bucketItem) {
+					return new FullItemFluidStorage(context, Items.BUCKET, FluidVariant.of(bucketFluid), FluidConstants.BUCKET);
+				}
+			}
+
+			return null;
+		});
+		// Register empty bottle storage, only water potion is supported!
+		combinedItemApiProvider(Items.GLASS_BOTTLE).register(context -> {
+			return new EmptyItemFluidStorage(context, emptyBottle -> {
+				ItemStack newStack = emptyBottle.toStack();
+				PotionUtil.setPotion(newStack, Potions.WATER);
+				return ItemVariant.of(Items.POTION, newStack.getTag());
+			}, Fluids.WATER, FluidConstants.BOTTLE);
+		});
+		// Register water potion storage
+		combinedItemApiProvider(Items.POTION).register(WaterPotionStorage::find);
 	}
 }
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/EmptyItemFluidStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/EmptyItemFluidStorage.java
new file mode 100644
index 000000000..87a35c935
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/EmptyItemFluidStorage.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.fluid.base;
+
+import java.util.Iterator;
+import java.util.function.Function;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.minecraft.fluid.Fluid;
+import net.minecraft.item.Item;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.BlankVariantView;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.InsertionOnlyStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleViewIterator;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+/**
+ * Base implementation of a fluid storage for an empty item.
+ * The empty item can be filled with an exact amount of some fluid to yield a full item instead.
+ * The default behavior is to copy the NBT from the empty item to the full item,
+ * however there is a second constructor that allows customizing the mapping.
+ *
+ * <p>For example, an empty bucket could be registered to accept exactly 81000 droplets of water and turn into a water bucket, like that:
+ * <pre>{@code
+ * FluidStorage.combinedItemApiProvider(Items.BUCKET) // Go through the combined API provider to make sure other mods can provide storages for empty buckets.
+ *             .register(context -> {// Register a provider for the bucket, returning a new storage every time:
+ *                 return new EmptyItemFluidStorage(
+ *                     context, // Pass the context.
+ *                     Items.WATER_BUCKET, // The result after fluid is inserted.
+ *                     Fluids.WATER, // Which fluid to accept.
+ *                     FluidConstants.BUCKET // How much fluid to accept.
+ *                 );
+ *             });
+ * }</pre>
+ * (This is just for illustration purposes! In practice, Fabric API already registers storages for most buckets,
+ * and it is inefficient to have one storage registered per fluid
+ * so Fabric API has a storage that accepts any fluid with a corresponding full bucket).
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public final class EmptyItemFluidStorage implements InsertionOnlyStorage<FluidVariant> {
+	private final ContainerItemContext context;
+	private final Item emptyItem;
+	private final Function<ItemVariant, ItemVariant> emptyToFullMapping;
+	private final Fluid insertableFluid;
+	private final long insertableAmount;
+
+	/**
+	 * Create a new instance.
+	 *
+	 * @param context The current context.
+	 * @param fullItem The new item after a successful fill operation.
+	 * @param insertableFluid The fluid that can be inserted. Fluid variant NBT is ignored.
+	 * @param insertableAmount The amount of fluid that can be inserted.
+	 */
+	public EmptyItemFluidStorage(ContainerItemContext context, Item fullItem, Fluid insertableFluid, long insertableAmount) {
+		this(context, emptyVariant -> ItemVariant.of(fullItem, emptyVariant.getNbt()), insertableFluid, insertableAmount);
+	}
+
+	/**
+	 * Create a new instance, with a custom mapping function.
+	 * The mapping function allows customizing how the NBT of the full item depends on the NBT of the empty item.
+	 * The default behavior with the other constructor is to just copy the full NBT.
+	 *
+	 * @param context The current context.
+	 * @param emptyToFullMapping A function mapping the empty item variant, to the variant that should be used for the full item.
+	 * @param insertableFluid The fluid that can be inserted. Fluid variant NBT is ignored on insertion.
+	 * @param insertableAmount The amount of fluid that can be inserted.
+	 * @see #EmptyItemFluidStorage(ContainerItemContext, Item, Fluid, long)
+	 */
+	public EmptyItemFluidStorage(ContainerItemContext context, Function<ItemVariant, ItemVariant> emptyToFullMapping, Fluid insertableFluid, long insertableAmount) {
+		StoragePreconditions.notNegative(insertableAmount);
+
+		this.context = context;
+		this.emptyItem = context.getItemVariant().getItem();
+		this.emptyToFullMapping = emptyToFullMapping;
+		this.insertableFluid = insertableFluid;
+		this.insertableAmount = insertableAmount;
+	}
+
+	@Override
+	public long insert(FluidVariant resource, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+		// Can't insert if the item is not emptyItem anymore.
+		if (!context.getItemVariant().isOf(emptyItem)) return 0;
+
+		// Make sure that the fluid and amount match.
+		if (resource.isOf(insertableFluid) && maxAmount >= insertableAmount) {
+			// If that's ok, just convert one of the empty item into the full item, with the mapping function.
+			ItemVariant newVariant = emptyToFullMapping.apply(context.getItemVariant());
+
+			if (context.exchange(newVariant, 1, transaction) == 1) {
+				// Conversion ok!
+				return insertableAmount;
+			}
+		}
+
+		return 0;
+	}
+
+	@Override
+	public Iterator<StorageView<FluidVariant>> iterator(TransactionContext transaction) {
+		return SingleViewIterator.create(new BlankVariantView<>(FluidVariant.blank(), insertableAmount), transaction);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/FullItemFluidStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/FullItemFluidStorage.java
new file mode 100644
index 000000000..a3552dfb1
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/FullItemFluidStorage.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.fluid.base;
+
+import java.util.function.Function;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.minecraft.item.Item;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.ExtractionOnlyStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+/**
+ * Base implementation of a fluid storage for a full item.
+ * The full item contains some fixed amount of a fluid variant, which can be extracted entirely to yield an empty item.
+ * The default behavior is to copy the NBT from the full item to the empty item,
+ * however there is a second constructor that allows customizing the mapping.
+ *
+ * <p>This is used similarly to {@link EmptyItemFluidStorage}.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public final class FullItemFluidStorage implements ExtractionOnlyStorage<FluidVariant>, SingleSlotStorage<FluidVariant> {
+	private final ContainerItemContext context;
+	private final Item fullItem;
+	private final Function<ItemVariant, ItemVariant> fullToEmptyMapping;
+	private final FluidVariant containedFluid;
+	private final long containedAmount;
+
+	/**
+	 * Create a new instance.
+	 *
+	 * @param context The current context.
+	 * @param emptyItem The new item after a successful extract operation.
+	 * @param containedFluid The contained fluid variant.
+	 * @param containedAmount How much of {@code containedFluid} is contained.
+	 */
+	public FullItemFluidStorage(ContainerItemContext context, Item emptyItem, FluidVariant containedFluid, long containedAmount) {
+		this(context, fullVariant -> ItemVariant.of(emptyItem, fullVariant.getNbt()), containedFluid, containedAmount);
+	}
+
+	/**
+	 * Create a new instance, with a custom mapping function.
+	 * The mapping function allows customizing how the NBT of the empty item depends on the NBT of the full item.
+	 * The default behavior with the other constructor is to just copy the full NBT.
+	 *
+	 * @param context The current context.
+	 * @param fullToEmptyMapping A function mapping the full item variant, to the variant that should be used
+	 *                           for the empty item after a successful extract operation.
+	 * @param containedFluid The contained fluid variant.
+	 * @param containedAmount How much of {@code containedFluid} is contained.
+	 */
+	public FullItemFluidStorage(ContainerItemContext context, Function<ItemVariant, ItemVariant> fullToEmptyMapping, FluidVariant containedFluid, long containedAmount) {
+		StoragePreconditions.notBlankNotNegative(containedFluid, containedAmount);
+
+		this.context = context;
+		this.fullItem = context.getItemVariant().getItem();
+		this.fullToEmptyMapping = fullToEmptyMapping;
+		this.containedFluid = containedFluid;
+		this.containedAmount = containedAmount;
+	}
+
+	@Override
+	public long extract(FluidVariant resource, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+		// If the context's item is not fullItem anymore, can't extract!
+		if (!context.getItemVariant().isOf(fullItem)) return 0;
+
+		// Make sure that the fluid and the amount match.
+		if (resource.equals(containedFluid) && maxAmount >= containedAmount) {
+			// If that's ok, just convert one of the full item into the empty item, copying the nbt.
+			ItemVariant newVariant = fullToEmptyMapping.apply(context.getItemVariant());
+
+			if (context.exchange(newVariant, 1, transaction) == 1) {
+				// Conversion ok!
+				return containedAmount;
+			}
+		}
+
+		return 0;
+	}
+
+	@Override
+	public boolean isResourceBlank() {
+		return getResource().isBlank();
+	}
+
+	@Override
+	public FluidVariant getResource() {
+		// Only contains a resource if the item of the context is still this one.
+		if (context.getItemVariant().isOf(fullItem)) {
+			return containedFluid;
+		} else {
+			return FluidVariant.blank();
+		}
+	}
+
+	@Override
+	public long getAmount() {
+		if (context.getItemVariant().isOf(fullItem)) {
+			return containedAmount;
+		} else {
+			return 0;
+		}
+	}
+
+	@Override
+	public long getCapacity() {
+		// Capacity is the same as the amount.
+		return getAmount();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/SingleFluidStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/SingleFluidStorage.java
index 588dc468a..ef915c154 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/SingleFluidStorage.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/fluid/base/SingleFluidStorage.java
@@ -22,22 +22,15 @@ import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
 import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.ResourceAmount;
 import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleVariantStorage;
 import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
 import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
 
 /**
- * A storage that can store a single fluid variant at any given time.
- * Implementors should at least override {@link #getCapacity(FluidVariant)}, and probably {@link #markDirty} as well.
- *
- * <p>{@link #canInsert} and {@link #canExtract} can be used for more precise control over which fluids may be inserted or extracted.
- * If one of these two functions is overridden to always return false, implementors may also wish to override
- * {@link #supportsInsertion} and/or {@link #supportsExtraction}.
- *
- * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
- * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ * @deprecated Superseded by {@link SingleVariantStorage}. Will be removed in a future iteration of the API.
  */
 @ApiStatus.Experimental
-@Deprecated
+@Deprecated(forRemoval = true)
 public abstract class SingleFluidStorage extends SnapshotParticipant<ResourceAmount<FluidVariant>> implements SingleSlotStorage<FluidVariant> {
 	public FluidVariant fluidVariant = FluidVariant.blank();
 	public long amount;
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/InventoryStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/InventoryStorage.java
new file mode 100644
index 000000000..1612ea3ec
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/InventoryStorage.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.item;
+
+import java.util.List;
+import java.util.Objects;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.inventory.Inventory;
+import net.minecraft.inventory.SidedInventory;
+import net.minecraft.inventory.SimpleInventory;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.impl.transfer.item.InventoryStorageImpl;
+
+/**
+ * An implementation of {@code Storage<ItemVariant>} for vanilla's {@link Inventory}, {@link SidedInventory} and {@link PlayerInventory}.
+ *
+ * <p>{@code Inventory} is often nicer to implement than {@code Storage<ItemVariant>}, but harder to use for item transfer.
+ * This wrapper allows one to have the best of both worlds, for example by storing a subclass of {@link SimpleInventory} in a block entity class,
+ * while exposing it as a {@code Storage<ItemVariant>} to {@linkplain ItemStorage#SIDED the item transfer API}.
+ *
+ * <p>In particular, note that {@link #getSlots} can be combined with {@link CombinedStorage} to retrieve a wrapper around a specific range of slots.
+ *
+ * <p><b>Important note:</b> This wrapper assumes that the inventory owns its slots.
+ * If the inventory does not own its slots, for example because it delegates to another inventory, this wrapper should not be used!
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+@ApiStatus.NonExtendable
+public interface InventoryStorage extends Storage<ItemVariant> {
+	/**
+	 * Return a wrapper around an {@link Inventory}.
+	 *
+	 * <p>If the inventory is a {@link SidedInventory} and the direction is nonnull, the wrapper wraps the sided inventory from the given direction.
+	 * The returned wrapper contains only the slots with the indices returned by {@link SidedInventory#getAvailableSlots} at query time.
+	 *
+	 * @param inventory The inventory to wrap.
+	 * @param direction The direction to use if the access is sided, or {@code null} if the access is not sided.
+	 */
+	static InventoryStorage of(Inventory inventory, @Nullable Direction direction) {
+		Objects.requireNonNull(inventory, "Null inventory is not supported.");
+		return InventoryStorageImpl.of(inventory, direction);
+	}
+
+	/**
+	 * Retrieve an unmodifiable list of the wrappers for the slots in this inventory.
+	 * Each wrapper corresponds to a single slot in the inventory.
+	 */
+	List<SingleSlotStorage<ItemVariant>> getSlots();
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java
new file mode 100644
index 000000000..85eec4ff2
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemStorage.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.item;
+
+import java.util.List;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.minecraft.block.Blocks;
+import net.minecraft.block.ChestBlock;
+import net.minecraft.block.entity.ChestBlockEntity;
+import net.minecraft.inventory.Inventory;
+import net.minecraft.inventory.SidedInventory;
+import net.minecraft.inventory.SimpleInventory;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.lookup.v1.block.BlockApiLookup;
+import net.fabricmc.fabric.api.transfer.v1.item.base.SingleStackStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
+import net.fabricmc.fabric.impl.transfer.item.ComposterWrapper;
+import net.fabricmc.fabric.mixin.transfer.DoubleInventoryAccessor;
+
+/**
+ * Access to {@link Storage Storage&lt;ItemVariant&gt;} instances.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public final class ItemStorage {
+	/**
+	 * Sided block access to item variant storages.
+	 * The {@code Direction} parameter may never be null.
+	 * Refer to {@link BlockApiLookup} for documentation on how to use this field.
+	 *
+	 * <p>When the operations supported by a storage change,
+	 * that is if the return value of {@link Storage#supportsInsertion} or {@link Storage#supportsExtraction} changes,
+	 * the storage should notify its neighbors with a block update so that they can refresh their connections if necessary.
+	 *
+	 * <p>Block entities directly implementing {@link Inventory} or {@link SidedInventory} are automatically handled by a fallback provider,
+	 * and don't need to do anything.
+	 * The fallback provider assumes that the {@link Inventory} "owns" its contents. If that's not the case,
+	 * for example because it redirects all function calls to another inventory, then implementing {@link Inventory} should be avoided.
+	 *
+	 * <p>Hoppers and droppers will interact with storages exposed through this lookup, thus implementing one of the vanilla APIs is not necessary.
+	 *
+	 * <p>Depending on the use case, the following strategies can be used to offer a {@code Storage<ItemVariant>} implementation:
+	 * <ul>
+	 *     <li>Directly implementing {@code Inventory} or {@code SidedInventory} on a block entity - it will be wrapped automatically.</li>
+	 *     <li>Storing an inventory inside a block entity field, and converting it manually with {@link InventoryStorage#of}.
+	 *     {@link SimpleInventory} can be used for easy implementation.</li>
+	 *     <li>{@link SingleStackStorage} can also be used for more flexibility. Multiple of them can be combined with {@link CombinedStorage}.</li>
+	 *     <li>Directly providing a custom implementation of {@code Storage<ItemVariant>} is also possible.</li>
+	 * </ul>
+	 */
+	public static final BlockApiLookup<Storage<ItemVariant>, Direction> SIDED =
+			BlockApiLookup.get(new Identifier("fabric:sided_item_storage"), Storage.asClass(), Direction.class);
+
+	private ItemStorage() {
+	}
+
+	static {
+		// Composter support.
+		ItemStorage.SIDED.registerForBlocks((world, pos, state, blockEntity, direction) -> ComposterWrapper.get(world, pos, direction), Blocks.COMPOSTER);
+
+		// Register Inventory fallback.
+		ItemStorage.SIDED.registerFallback((world, pos, state, blockEntity, direction) -> {
+			Inventory inventoryToWrap = null;
+
+			if (blockEntity instanceof Inventory inventory) {
+				if (blockEntity instanceof ChestBlockEntity && state.getBlock() instanceof ChestBlock chestBlock) {
+					inventoryToWrap = ChestBlock.getInventory(chestBlock, state, world, pos, true);
+
+					// For double chests, we need to retrieve a wrapper for each part separately.
+					if (inventoryToWrap instanceof DoubleInventoryAccessor accessor) {
+						Storage<ItemVariant> first = InventoryStorage.of(accessor.fabric_getFirst(), direction);
+						Storage<ItemVariant> second = InventoryStorage.of(accessor.fabric_getSecond(), direction);
+
+						return new CombinedStorage<>(List.of(first, second));
+					}
+				} else {
+					inventoryToWrap = inventory;
+				}
+			}
+
+			return inventoryToWrap != null ? InventoryStorage.of(inventoryToWrap, direction) : null;
+		});
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemVariant.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemVariant.java
new file mode 100644
index 000000000..e7c29f2b0
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/ItemVariant.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.item;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemConvertible;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.nbt.NbtCompound;
+import net.minecraft.network.PacketByteBuf;
+
+import net.fabricmc.fabric.impl.transfer.item.ItemVariantImpl;
+import net.fabricmc.fabric.api.transfer.v1.storage.TransferVariant;
+
+/**
+ * An immutable count-less ItemStack, i.e. an immutable association of an item and an optional NBT compound tag.
+ *
+ * <p>Do not implement, use the static {@code of(...)} functions instead.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+@ApiStatus.NonExtendable
+public interface ItemVariant extends TransferVariant<Item> {
+	/**
+	 * Retrieve a blank ItemVariant.
+	 */
+	static ItemVariant blank() {
+		return of(Items.AIR);
+	}
+
+	/**
+	 * Retrieve an ItemVariant with the item and tag of a stack.
+	 */
+	static ItemVariant of(ItemStack stack) {
+		return of(stack.getItem(), stack.getTag());
+	}
+
+	/**
+	 * Retrieve an ItemVariant with an item and without a tag.
+	 */
+	static ItemVariant of(ItemConvertible item) {
+		return of(item, null);
+	}
+
+	/**
+	 * Retrieve an ItemVariant with an item and an optional tag.
+	 */
+	static ItemVariant of(ItemConvertible item, @Nullable NbtCompound tag) {
+		return ItemVariantImpl.of(item.asItem(), tag);
+	}
+
+	/**
+	 * Return true if the item and tag of this variant match those of the passed stack, and false otherwise.
+	 */
+	default boolean matches(ItemStack stack) {
+		return isOf(stack.getItem()) && nbtMatches(stack.getTag());
+	}
+
+	/**
+	 * Return the item of this variant.
+	 */
+	default Item getItem() {
+		return getObject();
+	}
+
+	/**
+	 * Create a new item stack with count 1 from this variant.
+	 */
+	default ItemStack toStack() {
+		return toStack(1);
+	}
+
+	/**
+	 * Create a new item stack from this variant.
+	 *
+	 * @param count The count of the returned stack. It may lead to counts higher than maximum stack size.
+	 */
+	default ItemStack toStack(int count) {
+		if (isBlank()) return ItemStack.EMPTY;
+		ItemStack stack = new ItemStack(getItem(), count);
+		stack.setTag(copyNbt());
+		return stack;
+	}
+
+	/**
+	 * Deserialize a variant from an NBT compound tag, assuming it was serialized using
+	 * {@link #toNbt}. If an error occurs during deserialization, it will be logged
+	 * with the DEBUG level, and a blank variant will be returned.
+	 */
+	static ItemVariant fromNbt(NbtCompound nbt) {
+		return ItemVariantImpl.fromNbt(nbt);
+	}
+
+	/**
+	 * Write a variant from a packet byte buffer, assuming it was serialized using
+	 * {@link #toPacket}.
+	 */
+	static ItemVariant fromPacket(PacketByteBuf buf) {
+		return ItemVariantImpl.fromPacket(buf);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/PlayerInventoryStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/PlayerInventoryStorage.java
new file mode 100644
index 000000000..b827af23d
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/PlayerInventoryStorage.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.item;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.screen.ScreenHandler;
+
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.impl.transfer.item.CursorSlotWrapper;
+
+/**
+ * A {@code Storage<ItemVariant>} implementation for a {@link PlayerInventory}.
+ * This is a specialized version of {@link InventoryStorage},
+ * with an additional transactional wrapper for {@link PlayerInventory#offerOrDrop}.
+ *
+ * <p>Note that this is a wrapper around all the slots of the player inventory.
+ * This may cause direct insertion to insert arbitrary items into equipment slots or other unexpected behavior.
+ * To prevent this, {@link #offerOrDrop} is recommended for simple insertions.
+ * {@link #getSlots} can also be used and combined with {@link CombinedStorage} to retrieve a wrapper around a specific range of slots.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+@ApiStatus.NonExtendable
+public interface PlayerInventoryStorage extends InventoryStorage {
+	/**
+	 * Return an instance for the passed player's inventory.
+	 */
+	static PlayerInventoryStorage of(PlayerEntity player) {
+		return of(player.getInventory());
+	}
+
+	/**
+	 * Return an instance for the passed player inventory.
+	 */
+	static PlayerInventoryStorage of(PlayerInventory playerInventory) {
+		return (PlayerInventoryStorage) InventoryStorage.of(playerInventory, null);
+	}
+
+	/**
+	 * Return a wrapper around the cursor slot of a screen handler,
+	 * i.e. the stack that can be manipulated with {@link ScreenHandler#getCursorStack()} and {@link ScreenHandler#setCursorStack}.
+	 */
+	static SingleSlotStorage<ItemVariant> getCursorStorage(ScreenHandler screenHandler) {
+		return CursorSlotWrapper.get(screenHandler);
+	}
+
+	/**
+	 * Add items to the inventory if possible, and drop any leftover items in the world, similar to {@link PlayerInventory#offerOrDrop}
+	 *
+	 * <p>Note: This function has full transaction support, and will not actually drop the items until the outermost transaction is committed.
+	 *
+	 * @param variant The variant to insert.
+	 * @param amount How many of the variant to insert.
+	 * @param transaction The transaction this operation is part of.
+	 */
+	void offerOrDrop(ItemVariant variant, long amount, TransactionContext transaction);
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/base/SingleStackStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/base/SingleStackStorage.java
new file mode 100644
index 000000000..e29936a4d
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/item/base/SingleStackStorage.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.item.base;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.minecraft.item.ItemStack;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
+
+/**
+ * An item variant storage backed by an {@link ItemStack}.
+ * Implementors should at least override {@link #getStack} and {@link #setStack},
+ * and probably {@link #onFinalCommit} as well for {@code markDirty()} and similar calls.
+ *
+ * <p>{@link #canInsert} and {@link #canExtract} can be used for more precise control over which items may be inserted or extracted.
+ * If one of these two functions is overridden to always return false, implementors may also wish to override
+ * {@link #supportsInsertion} and/or {@link #supportsExtraction}.
+ * {@link #getCapacity(ItemVariant)} can be overridden to change the maximum capacity depending on the item variant.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public abstract class SingleStackStorage extends SnapshotParticipant<ItemStack> implements SingleSlotStorage<ItemVariant> {
+	/**
+	 * Return the stack of this storage. It will be modified directly sometimes to avoid needless copies.
+	 * However, any mutation of the stack will directly be followed by a call to {@link #setStack}.
+	 * This means that either returning the backing stack directly or a copy is safe.
+	 *
+	 * @return The current stack.
+	 */
+	protected abstract ItemStack getStack();
+
+	/**
+	 * Set the stack of this storage.
+	 */
+	protected abstract void setStack(ItemStack stack);
+
+	/**
+	 * Return {@code true} if the passed non-blank item variant can be inserted, {@code false} otherwise.
+	 */
+	protected boolean canInsert(ItemVariant itemVariant) {
+		return true;
+	}
+
+	/**
+	 * Return {@code true} if the passed non-blank item variant can be extracted, {@code false} otherwise.
+	 */
+	protected boolean canExtract(ItemVariant itemVariant) {
+		return true;
+	}
+
+	/**
+	 * Return the maximum capacity of this storage for the passed item variant.
+	 * If the passed item variant is blank, an estimate should be returned.
+	 *
+	 * <p>If the capacity should be limited by the max count of the item, this function must take it into account.
+	 * For example, a storage with a maximum count of 4, or less for items that have a smaller max count,
+	 * should override this to return {@code Math.min(itemVariant.getItem().getMaxCount(), 4);}.
+	 *
+	 * @return The maximum capacity of this storage for the passed item variant.
+	 */
+	protected int getCapacity(ItemVariant itemVariant) {
+		return itemVariant.getItem().getMaxCount();
+	}
+
+	@Override
+	public final boolean isResourceBlank() {
+		return getResource().isBlank();
+	}
+
+	@Override
+	public final ItemVariant getResource() {
+		return ItemVariant.of(getStack());
+	}
+
+	@Override
+	public final long getAmount() {
+		return getStack().getCount();
+	}
+
+	@Override
+	public final long getCapacity() {
+		return getCapacity(getResource());
+	}
+
+	@Override
+	public final long insert(ItemVariant insertedVariant, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(insertedVariant, maxAmount);
+
+		ItemStack currentStack = getStack();
+
+		if ((insertedVariant.matches(currentStack) || currentStack.isEmpty()) && canInsert(insertedVariant)) {
+			int insertedAmount = (int) Math.min(maxAmount, getCapacity(insertedVariant) - currentStack.getCount());
+
+			if (insertedAmount > 0) {
+				updateSnapshots(transaction);
+
+				if (currentStack.isEmpty()) {
+					currentStack = insertedVariant.toStack(insertedAmount);
+				} else {
+					currentStack.increment(insertedAmount);
+				}
+
+				setStack(currentStack);
+			}
+
+			return insertedAmount;
+		}
+
+		return 0;
+	}
+
+	@Override
+	public final long extract(ItemVariant variant, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(variant, maxAmount);
+
+		ItemStack currentStack = getStack();
+
+		if (variant.matches(currentStack) && canExtract(variant)) {
+			int extracted = (int) Math.min(currentStack.getCount(), maxAmount);
+
+			if (extracted > 0) {
+				this.updateSnapshots(transaction);
+				currentStack.decrement(extracted);
+				setStack(currentStack);
+			}
+
+			return extracted;
+		}
+
+		return 0;
+	}
+
+	@Override
+	protected final ItemStack createSnapshot() {
+		return getStack().copy();
+	}
+
+	@Override
+	protected final void readSnapshot(ItemStack snapshot) {
+		setStack(snapshot);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/package-info.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/package-info.java
index 8c9ff62d9..4a5d7a3ea 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/package-info.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/package-info.java
@@ -39,16 +39,16 @@
  * wrong usage of {@code Storage} and {@code StorageView} methods.
  * </p>
  *
+ * <p>Implementors of transfer variant storages with a fixed number of "slots" or "tanks" can use
+ * {@link net.fabricmc.fabric.api.transfer.v1.storage.base.SingleVariantStorage SingleVariantStorage},
+ * and combine them with {@link net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage CombinedStorage}.
+ *
  * <p><h2>Fluid transfer</h2>
  * A {@code Storage<FluidVariant>} is any object that can store fluids. It is just a {@code Storage<T>}, where {@code T} is
  * {@link net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant FluidVariant}, the immutable combination of a {@code Fluid} and additional NBT data.
- * Instances can be accessed through the API lookup defined in {@link net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage FluidStorage}.
+ * Instances can be accessed through the API lookups defined in {@link net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage FluidStorage}.
  * </p>
  *
- * <p>Implementors of fluid inventories with a fixed number of "slots" or "tanks" can use
- * {@link net.fabricmc.fabric.api.transfer.v1.fluid.base.SingleFluidStorage SingleFluidStorage},
- * and combine them with {@link net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage CombinedStorage}.
- *
  * <p>The amount for fluid transfer is droplets, that is 1/81000ths of a bucket.
  * {@link net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants FluidConstants} contains a few helpful constants to work with droplets.
  *
@@ -56,5 +56,21 @@
  * ignoring the additional NBT data.
  * {@code Fluid}s that wish to render differently depending on the stored NBT data can register a
  * {@link net.fabricmc.fabric.api.transfer.v1.client.fluid.FluidVariantRenderHandler FluidVariantRenderHandler}.
+ *
+ * <p><h2>Item transfer</h2>
+ * A {@code Storage<ItemVariant>} is any object that can store items.
+ * Instances can be accessed through the API lookup defined in {@link net.fabricmc.fabric.api.transfer.v1.item.ItemStorage ItemStorage}.
+ * </p>
+ *
+ * <p>The lookup already provides compatibility with vanilla inventories, however it may sometimes be interesting to use
+ * {@link net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage InventoryStorage} or
+ * {@link net.fabricmc.fabric.api.transfer.v1.item.PlayerInventoryStorage PlayerInventoryStorage} when interaction with
+ * {@code Inventory}-based APIs is required.
+ *
+ * <p><h2>{@code ContainerItemContext}</h2>
+ * {@link net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext ContainerItemContext} is a context designed for {@code ItemApiLookup} queries
+ * that allows the returned APIs to interact with the containing inventory.
+ * Notably, it is used by the {@code FluidStorage.ITEM} lookup for fluid-containing items.
+ * </p>
  */
 package net.fabricmc.fabric.api.transfer.v1;
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/Storage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/Storage.java
index 1c38def4e..318021466 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/Storage.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/Storage.java
@@ -119,6 +119,7 @@ public interface Storage<T> {
 	/**
 	 * Iterate through the contents of this storage, for the scope of the passed transaction.
 	 * Every visited {@link StorageView} represents a stored resource and an amount.
+	 * The iterator doesn't guarantee that a single resource only occurs once during an iteration.
 	 *
 	 * <p>The returned iterator and any view it returns are only valid for the scope of to the passed transaction.
 	 * They should not be used once that transaction is closed.
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/StorageUtil.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/StorageUtil.java
index 460bdc195..2a8eff654 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/StorageUtil.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/StorageUtil.java
@@ -21,6 +21,11 @@ import java.util.function.Predicate;
 import org.jetbrains.annotations.ApiStatus;
 import org.jetbrains.annotations.Nullable;
 
+import net.minecraft.inventory.Inventory;
+import net.minecraft.screen.ScreenHandler;
+import net.minecraft.util.math.MathHelper;
+
+import net.fabricmc.fabric.api.transfer.v1.storage.base.ResourceAmount;
 import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
 
@@ -73,7 +78,7 @@ public final class StorageUtil {
 
 		long totalMoved = 0;
 
-		try (Transaction iterationTransaction = (transaction == null ? Transaction.openOuter() : transaction.openNested())) {
+		try (Transaction iterationTransaction = Transaction.openNested(transaction)) {
 			for (StorageView<T> view : from.iterable(iterationTransaction)) {
 				if (view.isResourceBlank()) continue;
 				T resource = view.getResource();
@@ -154,7 +159,7 @@ public final class StorageUtil {
 	public static <T> T findExtractableResource(@Nullable Storage<T> storage, @Nullable TransactionContext transaction) {
 		if (storage == null) return null;
 
-		try (Transaction nested = transaction == null ? Transaction.openOuter() : transaction.openNested()) {
+		try (Transaction nested = Transaction.openNested(transaction)) {
 			for (StorageView<T> view : storage.iterable(nested)) {
 				// Extract below could change the resource, so we have to query it before extracting.
 				T resource = view.getResource();
@@ -168,4 +173,67 @@ public final class StorageUtil {
 
 		return null;
 	}
+
+	/**
+	 * Attempt to find a resource stored in the passed storage that can be extracted, and how much of it can be extracted.
+	 *
+	 * @param storage The storage to inspect, may be null.
+	 * @param transaction The current transaction, or {@code null} if a transaction should be opened for this query.
+	 * @param <T> The type of the stored resources.
+	 * @return A non-blank resource stored in the storage that can be extracted and the strictly positive amount of it that can be extracted,
+	 * or {@code null} if none could be found.
+	 */
+	@Nullable
+	public static <T> ResourceAmount<T> findExtractableContent(@Nullable Storage<T> storage, @Nullable TransactionContext transaction) {
+		T extractableResource = findExtractableResource(storage, transaction);
+
+		if (extractableResource != null) {
+			try (Transaction nested = Transaction.openNested(transaction)) {
+				long extractableAmount = storage.extract(extractableResource, Long.MAX_VALUE, nested);
+
+				if (extractableAmount > 0) {
+					return new ResourceAmount<>(extractableResource, extractableAmount);
+				}
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Compute the comparator output for a storage, similar to {@link ScreenHandler#calculateComparatorOutput(Inventory)}.
+	 *
+	 * @param storage The storage for which the comparator level should be computed.
+	 * @param transaction The current transaction, or {@code null} if a transaction should be opened for this computation.
+	 * @param <T> The type of the stored resources.
+	 * @return An integer between 0 and 15 (inclusive): the comparator output for the passed storage.
+	 */
+	public static <T> int calculateComparatorOutput(@Nullable Storage<T> storage, @Nullable TransactionContext transaction) {
+		if (storage == null) return 0;
+
+		if (transaction == null) {
+			try (Transaction outer = Transaction.openOuter()) {
+				return calculateComparatorOutputInner(storage, outer);
+			}
+		} else {
+			return calculateComparatorOutputInner(storage, transaction);
+		}
+	}
+
+	private static <T> int calculateComparatorOutputInner(Storage<T> storage, TransactionContext transaction) {
+		double fillPercentage = 0;
+		int viewCount = 0;
+		boolean hasNonEmptyView = false;
+
+		for (StorageView<T> view : storage.iterable(transaction)) {
+			viewCount++;
+
+			if (view.getAmount() > 0) {
+				fillPercentage += (double) view.getAmount() / view.getCapacity();
+				hasNonEmptyView = true;
+			}
+		}
+
+		return MathHelper.floor(fillPercentage / viewCount * 14) + (hasNonEmptyView ? 1 : 0);
+	}
 }
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/BlankVariantView.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/BlankVariantView.java
new file mode 100644
index 000000000..f4c4eca84
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/BlankVariantView.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.storage.base;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
+import net.fabricmc.fabric.api.transfer.v1.storage.TransferVariant;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+/**
+ * A transfer variant storage view that contains a blank variant all the time (it's always empty), but may have a nonzero capacity.
+ * This can be used to give capacity hints even if the storage is empty.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public class BlankVariantView<T extends TransferVariant<?>> implements StorageView<T> {
+	private final T blankVariant;
+	private final long capacity;
+
+	/**
+	 * Create a new instance.
+	 * @throws IllegalArgumentException If the passed {@code blankVariant} is not blank.
+	 */
+	public BlankVariantView(T blankVariant, long capacity) {
+		if (!blankVariant.isBlank()) {
+			throw new IllegalArgumentException("Expected a blank variant, received " + blankVariant);
+		}
+
+		this.blankVariant = blankVariant;
+		this.capacity = capacity;
+	}
+
+	@Override
+	public long extract(T resource, long maxAmount, TransactionContext transaction) {
+		return 0; // can't extract
+	}
+
+	@Override
+	public boolean isResourceBlank() {
+		return true;
+	}
+
+	@Override
+	public T getResource() {
+		return blankVariant;
+	}
+
+	@Override
+	public long getAmount() {
+		return 0;
+	}
+
+	@Override
+	public long getCapacity() {
+		return capacity;
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/CombinedStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/CombinedStorage.java
index f0a18eae4..2a71ff98f 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/CombinedStorage.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/CombinedStorage.java
@@ -42,7 +42,7 @@ import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 @ApiStatus.Experimental
 @Deprecated
 public class CombinedStorage<T, S extends Storage<T>> implements Storage<T> {
-	public final List<S> parts;
+	public List<S> parts;
 
 	public CombinedStorage(List<S> parts) {
 		this.parts = parts;
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/SingleVariantStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/SingleVariantStorage.java
new file mode 100644
index 000000000..ba3badc50
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/storage/base/SingleVariantStorage.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.transfer.v1.storage.base;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.TransferVariant;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
+
+/**
+ * A storage that can store a single transfer variant at any given time.
+ * Implementors should at least override {@link #getCapacity(TransferVariant)},
+ * and probably {@link #onFinalCommit} as well for {@code markDirty()} and similar calls.
+ *
+ * <p>{@link #canInsert} and {@link #canExtract} can be used for more precise control over which variants may be inserted or extracted.
+ * If one of these two functions is overridden to always return false, implementors may also wish to override
+ * {@link #supportsInsertion} and/or {@link #supportsExtraction}.
+ *
+ * @deprecated Experimental feature, we reserve the right to remove or change it without further notice.
+ * The transfer API is a complex addition, and we want to be able to correct possible design mistakes.
+ */
+@ApiStatus.Experimental
+@Deprecated
+public abstract class SingleVariantStorage<T extends TransferVariant<?>> extends SnapshotParticipant<ResourceAmount<T>> implements SingleSlotStorage<T> {
+	public T variant = getBlankVariant();
+	public long amount = 0;
+
+	/**
+	 * Return the blank variant.
+	 */
+	protected abstract T getBlankVariant();
+
+	/**
+	 * Return the maximum capacity of this storage for the passed transfer variant.
+	 * If the passed variant is blank, an estimate should be returned.
+	 */
+	protected abstract long getCapacity(T variant);
+
+	/**
+	 * @return {@code true} if the passed non-blank variant can be inserted, {@code false} otherwise.
+	 */
+	protected boolean canInsert(T variant) {
+		return true;
+	}
+
+	/**
+	 * @return {@code true} if the passed non-blank variant can be extracted, {@code false} otherwise.
+	 */
+	protected boolean canExtract(T variant) {
+		return true;
+	}
+
+	@Override
+	public long insert(T insertedVariant, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(insertedVariant, maxAmount);
+
+		if ((insertedVariant.equals(variant) || variant.isBlank()) && canInsert(insertedVariant)) {
+			long insertedAmount = Math.min(maxAmount, getCapacity(insertedVariant) - amount);
+
+			if (insertedAmount > 0) {
+				updateSnapshots(transaction);
+
+				if (variant.isBlank()) {
+					variant = insertedVariant;
+					amount = insertedAmount;
+				} else {
+					amount += insertedAmount;
+				}
+			}
+
+			return insertedAmount;
+		}
+
+		return 0;
+	}
+
+	@Override
+	public long extract(T extractedVariant, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(extractedVariant, maxAmount);
+
+		if (extractedVariant.equals(variant) && canExtract(extractedVariant)) {
+			long extractedAmount = Math.min(maxAmount, amount);
+
+			if (extractedAmount > 0) {
+				updateSnapshots(transaction);
+				amount -= extractedAmount;
+
+				if (amount == 0) {
+					variant = getBlankVariant();
+				}
+			}
+
+			return extractedAmount;
+		}
+
+		return 0;
+	}
+
+	@Override
+	public boolean isResourceBlank() {
+		return variant.isBlank();
+	}
+
+	@Override
+	public T getResource() {
+		return variant;
+	}
+
+	@Override
+	public long getAmount() {
+		return amount;
+	}
+
+	@Override
+	public long getCapacity() {
+		return getCapacity(variant);
+	}
+
+	@Override
+	protected ResourceAmount<T> createSnapshot() {
+		return new ResourceAmount<>(variant, amount);
+	}
+
+	@Override
+	protected void readSnapshot(ResourceAmount<T> snapshot) {
+		variant = snapshot.resource();
+		amount = snapshot.amount();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/Transaction.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/Transaction.java
index 8ce71d7c0..ff3f6110f 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/Transaction.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/Transaction.java
@@ -17,6 +17,7 @@
 package net.fabricmc.fabric.api.transfer.v1.transaction;
 
 import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
 
 import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
 import net.fabricmc.fabric.impl.transfer.transaction.TransactionManagerImpl;
@@ -96,6 +97,13 @@ public interface Transaction extends AutoCloseable, TransactionContext {
 		return TransactionManagerImpl.MANAGERS.get().isOpen();
 	}
 
+	/**
+	 * Open a nested transaction if {@code maybeParent} is non null, or an outer transaction if {@code maybeParent} is null.
+	 */
+	static Transaction openNested(@Nullable TransactionContext maybeParent) {
+		return maybeParent == null ? openOuter() : maybeParent.openNested();
+	}
+
 	/**
 	 * Close the current transaction, rolling back all the changes that happened during this transaction and
 	 * the transactions opened with {@link #openNested} from this transaction.
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/base/SnapshotParticipant.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/base/SnapshotParticipant.java
index 23d49e6a2..6851048f2 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/base/SnapshotParticipant.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/api/transfer/v1/transaction/base/SnapshotParticipant.java
@@ -79,7 +79,7 @@ public abstract class SnapshotParticipant<T> implements Transaction.CloseCallbac
 	 * committed or rolled back.
 	 * This function should be called every time the participant is about to change its internal state as part of a transaction.
 	 */
-	public final void updateSnapshots(TransactionContext transaction) {
+	public void updateSnapshots(TransactionContext transaction) {
 		// Make sure we have enough storage for snapshots
 		while (snapshots.size() <= transaction.nestingDepth()) {
 			snapshots.add(null);
@@ -96,7 +96,7 @@ public abstract class SnapshotParticipant<T> implements Transaction.CloseCallbac
 	}
 
 	@Override
-	public final void onClose(TransactionContext transaction, Transaction.Result result) {
+	public void onClose(TransactionContext transaction, Transaction.Result result) {
 		// Get and remove the relevant snapshot.
 		T snapshot = snapshots.set(transaction.nestingDepth(), null);
 
@@ -121,7 +121,7 @@ public abstract class SnapshotParticipant<T> implements Transaction.CloseCallbac
 	}
 
 	@Override
-	public final void afterOuterClose(Transaction.Result result) {
+	public void afterOuterClose(Transaction.Result result) {
 		// The result is guaranteed to be COMMITTED,
 		// as this is only scheduled during onClose() when the outer transaction is successful.
 		onFinalCommit();
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/InitialContentsContainerItemContext.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/InitialContentsContainerItemContext.java
new file mode 100644
index 000000000..cd8599956
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/InitialContentsContainerItemContext.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.context;
+
+import java.util.Collections;
+import java.util.List;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleVariantStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+public class InitialContentsContainerItemContext implements ContainerItemContext {
+	private final SingleVariantStorage<ItemVariant> backingSlot = new SingleVariantStorage<>() {
+		@Override
+		protected ItemVariant getBlankVariant() {
+			return ItemVariant.blank();
+		}
+
+		@Override
+		protected long getCapacity(ItemVariant variant) {
+			return Long.MAX_VALUE;
+		}
+	};
+
+	public InitialContentsContainerItemContext(ItemVariant initialVariant, long initialAmount) {
+		backingSlot.variant = initialVariant;
+		backingSlot.amount = initialAmount;
+	}
+
+	@Override
+	public SingleSlotStorage<ItemVariant> getMainSlot() {
+		return backingSlot;
+	}
+
+	@Override
+	public long insertOverflow(ItemVariant itemVariant, long maxAmount, TransactionContext transactionContext) {
+		StoragePreconditions.notBlankNotNegative(itemVariant, maxAmount);
+		// Always allow anything to be inserted.
+		return maxAmount;
+	}
+
+	@Override
+	public List<SingleSlotStorage<ItemVariant>> getAdditionalSlots() {
+		return Collections.emptyList();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/PlayerContainerItemContext.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/PlayerContainerItemContext.java
new file mode 100644
index 000000000..739157432
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/PlayerContainerItemContext.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.context;
+
+import java.util.List;
+import java.util.Objects;
+
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.util.Hand;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.PlayerInventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+public class PlayerContainerItemContext implements ContainerItemContext {
+	private final PlayerInventoryStorage playerWrapper;
+	private final SingleSlotStorage<ItemVariant> slot;
+
+	public PlayerContainerItemContext(PlayerEntity player, Hand hand) {
+		Objects.requireNonNull(hand, "Hand may not be null.");
+
+		this.playerWrapper = PlayerInventoryStorage.of(player);
+		int slotIndex = hand == Hand.MAIN_HAND ? player.getInventory().selectedSlot : PlayerInventory.OFF_HAND_SLOT;
+		this.slot = playerWrapper.getSlots().get(slotIndex);
+	}
+
+	public PlayerContainerItemContext(PlayerEntity player, SingleSlotStorage<ItemVariant> slot) {
+		this.playerWrapper = PlayerInventoryStorage.of(player);
+		this.slot = slot;
+	}
+
+	@Override
+	public SingleSlotStorage<ItemVariant> getMainSlot() {
+		return slot;
+	}
+
+	@Override
+	public long insertOverflow(ItemVariant itemVariant, long maxAmount, TransactionContext transactionContext) {
+		playerWrapper.offerOrDrop(itemVariant, maxAmount, transactionContext);
+		return maxAmount;
+	}
+
+	@Override
+	public List<SingleSlotStorage<ItemVariant>> getAdditionalSlots() {
+		return playerWrapper.getSlots();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/SingleSlotContainerItemContext.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/SingleSlotContainerItemContext.java
new file mode 100644
index 000000000..60c3650b0
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/context/SingleSlotContainerItemContext.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.context;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+public class SingleSlotContainerItemContext implements ContainerItemContext {
+	private final SingleSlotStorage<ItemVariant> slot;
+
+	public SingleSlotContainerItemContext(SingleSlotStorage<ItemVariant> slot) {
+		this.slot = Objects.requireNonNull(slot);
+	}
+
+	@Override
+	public SingleSlotStorage<ItemVariant> getMainSlot() {
+		return slot;
+	}
+
+	@Override
+	public long insertOverflow(ItemVariant itemVariant, long maxAmount, TransactionContext transactionContext) {
+		return 0;
+	}
+
+	@Override
+	public List<SingleSlotStorage<ItemVariant>> getAdditionalSlots() {
+		return Collections.emptyList();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CauldronStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CauldronStorage.java
index 3a04de685..ff467095a 100644
--- a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CauldronStorage.java
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CauldronStorage.java
@@ -54,8 +54,7 @@ public class CauldronStorage extends SnapshotParticipant<BlockState> implements
 
 	public static CauldronStorage get(World world, BlockPos pos) {
 		WorldLocation location = new WorldLocation(world, pos.toImmutable());
-		CAULDRONS.computeIfAbsent(location, CauldronStorage::new);
-		return CAULDRONS.get(location);
+		return CAULDRONS.computeIfAbsent(location, CauldronStorage::new);
 	}
 
 	private final WorldLocation location;
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CombinedProvidersImpl.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CombinedProvidersImpl.java
new file mode 100644
index 000000000..54ca2481d
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/CombinedProvidersImpl.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.fluid;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+
+import net.fabricmc.fabric.api.event.Event;
+import net.fabricmc.fabric.api.event.EventFactory;
+import net.fabricmc.fabric.api.lookup.v1.item.ItemApiLookup;
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
+
+public class CombinedProvidersImpl {
+	public static Event<FluidStorage.CombinedItemApiProvider> createEvent(boolean invokeFallback) {
+		return EventFactory.createArrayBacked(FluidStorage.CombinedItemApiProvider.class, listeners -> context -> {
+			List<Storage<FluidVariant>> storages = new ArrayList<>();
+
+			for (FluidStorage.CombinedItemApiProvider listener : listeners) {
+				Storage<FluidVariant> found = listener.find(context);
+
+				if (found != null) {
+					storages.add(found);
+				}
+			}
+
+			// Allow combining per-item combined providers with fallback combined providers.
+			if (!storages.isEmpty() && invokeFallback) {
+				// Only invoke the fallback if API Lookup doesn't invoke it right after,
+				// that is only invoke the fallback if storages were offered,
+				// otherwise we can wait for API Lookup to invoke the fallback provider itself.
+				Storage<FluidVariant> fallbackFound = FluidStorage.GENERAL_COMBINED_PROVIDER.invoker().find(context);
+
+				if (fallbackFound != null) {
+					storages.add(fallbackFound);
+				}
+			}
+
+			return storages.isEmpty() ? null : new CombinedStorage<>(storages);
+		});
+	}
+
+	private static class Provider implements ItemApiLookup.ItemApiProvider<Storage<FluidVariant>, ContainerItemContext> {
+		private final Event<FluidStorage.CombinedItemApiProvider> event = createEvent(true);
+
+		@Override
+		@Nullable
+		public Storage<FluidVariant> find(ItemStack itemStack, ContainerItemContext context) {
+			if (!context.getItemVariant().matches(itemStack)) {
+				String errorMessage = String.format(
+						"Query stack %s and ContainerItemContext variant %s don't match.",
+						itemStack,
+						context.getItemVariant()
+				);
+				throw new IllegalArgumentException(errorMessage);
+			}
+
+			return event.invoker().find(context);
+		}
+	}
+
+	public static Event<FluidStorage.CombinedItemApiProvider> getOrCreateItemEvent(Item item) {
+		// register here is thread-safe, so the query below will return a valid provider (possibly one registered before or from another thread).
+		FluidStorage.ITEM.registerForItems(new Provider(), item);
+		ItemApiLookup.ItemApiProvider<Storage<FluidVariant>, ContainerItemContext> existingProvider = FluidStorage.ITEM.getProvider(item);
+
+		if (existingProvider instanceof Provider registeredProvider) {
+			return registeredProvider.event;
+		} else {
+			String errorMessage = String.format(
+					"An incompatible provider was already registered for item %s. Provider: %s.",
+					item,
+					existingProvider
+			);
+			throw new IllegalStateException(errorMessage);
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/EmptyBucketStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/EmptyBucketStorage.java
new file mode 100644
index 000000000..50963272e
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/EmptyBucketStorage.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.fluid;
+
+import java.util.Iterator;
+
+import net.minecraft.item.Item;
+import net.minecraft.item.Items;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.BlankVariantView;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.InsertionOnlyStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleViewIterator;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.mixin.transfer.BucketItemAccessor;
+
+/**
+ * Storage implementation for empty buckets, accepting any fluid with a bidirectional fluid &lt;-&gt; bucket mapping.
+ */
+public class EmptyBucketStorage implements InsertionOnlyStorage<FluidVariant> {
+	private final ContainerItemContext context;
+
+	public EmptyBucketStorage(ContainerItemContext context) {
+		this.context = context;
+	}
+
+	@Override
+	public long insert(FluidVariant resource, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+		if (!context.getItemVariant().isOf(Items.BUCKET)) return 0;
+
+		Item fullBucket = resource.getFluid().getBucketItem();
+
+		// Make sure the resource is a correct fluid mapping: the fluid <-> bucket mapping must be bidirectional.
+		if (fullBucket instanceof BucketItemAccessor accessor && resource.isOf(accessor.fabric_getFluid())) {
+			if (maxAmount >= FluidConstants.BUCKET) {
+				ItemVariant newVariant = ItemVariant.of(fullBucket, context.getItemVariant().getNbt());
+
+				if (context.exchange(newVariant, 1, transaction) == 1) {
+					return FluidConstants.BUCKET;
+				}
+			}
+		}
+
+		return 0;
+	}
+
+	@Override
+	public Iterator<StorageView<FluidVariant>> iterator(TransactionContext transaction) {
+		return SingleViewIterator.create(new BlankVariantView<>(FluidVariant.blank(), FluidConstants.BUCKET), transaction);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/WaterPotionStorage.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/WaterPotionStorage.java
new file mode 100644
index 000000000..566fb7a66
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/fluid/WaterPotionStorage.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.fluid;
+
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.fluid.Fluids;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.potion.PotionUtil;
+import net.minecraft.potion.Potions;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.ExtractionOnlyStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+/**
+ * Implementation of the storage for a water potion.
+ */
+public class WaterPotionStorage implements ExtractionOnlyStorage<FluidVariant>, SingleSlotStorage<FluidVariant> {
+	private static final FluidVariant CONTAINED_FLUID = FluidVariant.of(Fluids.WATER);
+	private static final long CONTAINED_AMOUNT = FluidConstants.BOTTLE;
+
+	@Nullable
+	public static WaterPotionStorage find(ContainerItemContext context) {
+		return isWaterPotion(context) ? new WaterPotionStorage(context) : null;
+	}
+
+	private static boolean isWaterPotion(ContainerItemContext context) {
+		ItemVariant variant = context.getItemVariant();
+
+		return variant.isOf(Items.POTION) && PotionUtil.getPotion(variant.getNbt()) == Potions.WATER;
+	}
+
+	private final ContainerItemContext context;
+
+	private WaterPotionStorage(ContainerItemContext context) {
+		this.context = context;
+	}
+
+	private boolean isWaterPotion() {
+		return isWaterPotion(context);
+	}
+
+	private ItemVariant mapToGlassBottle() {
+		ItemStack newStack = context.getItemVariant().toStack();
+		PotionUtil.setPotion(newStack, Potions.EMPTY);
+		return ItemVariant.of(Items.GLASS_BOTTLE, newStack.getTag());
+	}
+
+	@Override
+	public long extract(FluidVariant resource, long maxAmount, TransactionContext transaction) {
+		StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+		// Not a water potion anymore
+		if (!isWaterPotion()) return 0;
+
+		// Make sure that the fluid and the amount match.
+		if (resource.equals(CONTAINED_FLUID) && maxAmount >= CONTAINED_AMOUNT) {
+			if (context.exchange(mapToGlassBottle(), 1, transaction) == 1) {
+				// Conversion ok!
+				return CONTAINED_AMOUNT;
+			}
+		}
+
+		return 0;
+	}
+
+	@Override
+	public boolean isResourceBlank() {
+		return getResource().isBlank();
+	}
+
+	@Override
+	public FluidVariant getResource() {
+		// Only contains a resource if this is still a water potion.
+		if (isWaterPotion()) {
+			return CONTAINED_FLUID;
+		} else {
+			return FluidVariant.blank();
+		}
+	}
+
+	@Override
+	public long getAmount() {
+		if (isWaterPotion()) {
+			return CONTAINED_AMOUNT;
+		} else {
+			return 0;
+		}
+	}
+
+	@Override
+	public long getCapacity() {
+		// Capacity is the same as the amount.
+		return getAmount();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ComposterWrapper.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ComposterWrapper.java
new file mode 100644
index 000000000..c92299653
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ComposterWrapper.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import static net.minecraft.util.math.Direction.UP;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Objects;
+
+import com.google.common.collect.MapMaker;
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.block.BlockState;
+import net.minecraft.block.ComposterBlock;
+import net.minecraft.item.Items;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Direction;
+import net.minecraft.world.World;
+import net.minecraft.world.WorldEvents;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageView;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.ExtractionOnlyStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.InsertionOnlyStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
+
+/**
+ * Implementation of {@code Storage<ItemVariant>} for composters.
+ */
+public class ComposterWrapper extends SnapshotParticipant<Float> {
+	// Record is used for convenient constructor, hashcode and equals implementations.
+	private record WorldLocation(World world, BlockPos pos) {
+		private BlockState getBlockState() {
+			return world.getBlockState(pos);
+		}
+
+		private void setBlockState(BlockState state) {
+			world.setBlockState(pos, state);
+		}
+	}
+
+	// Weak values to make sure wrappers are cleaned up after use, thread-safe.
+	// The two storages strongly reference the containing wrapper, so we are alright with weak values.
+	private static final Map<WorldLocation, ComposterWrapper> COMPOSTERS = new MapMaker().concurrencyLevel(1).weakValues().makeMap();
+
+	@Nullable
+	public static Storage<ItemVariant> get(World world, BlockPos pos, Direction direction) {
+		Objects.requireNonNull(direction);
+
+		if (direction.getAxis().isVertical()) {
+			WorldLocation location = new WorldLocation(world, pos.toImmutable());
+			ComposterWrapper composterWrapper = COMPOSTERS.computeIfAbsent(location, ComposterWrapper::new);
+			return direction == UP ? composterWrapper.upStorage : composterWrapper.downStorage;
+		} else {
+			return null;
+		}
+	}
+
+	private static final float DO_NOTHING = 0f;
+	private static final float EXTRACT_BONEMEAL = -1f;
+
+	private final WorldLocation location;
+	// -1 if bonemeal was extracted, otherwise the composter increase probability of the (pending) inserted item.
+	private Float increaseProbability = DO_NOTHING;
+	private final TopStorage upStorage = new TopStorage();
+	private final BottomStorage downStorage = new BottomStorage();
+
+	private ComposterWrapper(WorldLocation location) {
+		this.location = location;
+	}
+
+	@Override
+	protected Float createSnapshot() {
+		return increaseProbability;
+	}
+
+	@Override
+	protected void readSnapshot(Float snapshot) {
+		// Reset after unsuccessful commit.
+		increaseProbability = snapshot;
+	}
+
+	@Override
+	protected void onFinalCommit() {
+		// Apply pending action
+		if (increaseProbability == EXTRACT_BONEMEAL) {
+			// Mimic ComposterBlock#emptyComposter logic.
+			location.setBlockState(location.getBlockState().with(ComposterBlock.LEVEL, 0));
+			// Play the sound
+		} else if (increaseProbability > 0) {
+			boolean increaseSuccessful = location.world.getRandom().nextDouble() < increaseProbability;
+
+			if (increaseSuccessful) {
+				// Mimic ComposterBlock#addToComposter logic.
+				BlockState state = location.getBlockState();
+				int newLevel = state.get(ComposterBlock.LEVEL) + 1;
+				BlockState newState = state.with(ComposterBlock.LEVEL, newLevel);
+				location.setBlockState(newState);
+
+				if (newLevel == 7) {
+					location.world.getBlockTickScheduler().schedule(location.pos, state.getBlock(), 20);
+				}
+			}
+
+			location.world.syncWorldEvent(WorldEvents.COMPOSTER_USED, location.pos, increaseSuccessful ? 1 : 0);
+		}
+
+		// Reset after successful commit.
+		increaseProbability = DO_NOTHING;
+	}
+
+	private class TopStorage implements InsertionOnlyStorage<ItemVariant> {
+		@Override
+		public long insert(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+			StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+			// Check amount.
+			if (maxAmount < 1) return 0;
+			// Check that no action is scheduled.
+			if (increaseProbability != DO_NOTHING) return 0;
+			// Check that the composter can accept items.
+			if (location.getBlockState().get(ComposterBlock.LEVEL) >= 7) return 0;
+			// Check that the item is compostable.
+			float insertedIncreaseProbability = ComposterBlock.ITEM_TO_LEVEL_INCREASE_CHANCE.getFloat(resource.getItem());
+			if (insertedIncreaseProbability <= 0) return 0;
+
+			// Schedule insertion.
+			updateSnapshots(transaction);
+			increaseProbability = insertedIncreaseProbability;
+			return 1;
+		}
+
+		@Override
+		public Iterator<StorageView<ItemVariant>> iterator(TransactionContext transaction) {
+			return Collections.emptyIterator();
+		}
+	}
+
+	private class BottomStorage implements ExtractionOnlyStorage<ItemVariant>, SingleSlotStorage<ItemVariant> {
+		private static final ItemVariant BONE_MEAL = ItemVariant.of(Items.BONE_MEAL);
+
+		private boolean hasBoneMeal() {
+			// We only have bone meal if the level is 8 and no action was scheduled.
+			return increaseProbability == DO_NOTHING && location.getBlockState().get(ComposterBlock.LEVEL) == 8;
+		}
+
+		@Override
+		public long extract(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+			StoragePreconditions.notBlankNotNegative(resource, maxAmount);
+
+			// Check amount.
+			if (maxAmount < 1) return 0;
+			// Check that the resource is bone meal.
+			if (!BONE_MEAL.equals(resource)) return 0;
+			// Check that there is bone meal to extract.
+			if (!hasBoneMeal()) return 0;
+
+			updateSnapshots(transaction);
+			increaseProbability = EXTRACT_BONEMEAL;
+			return 1;
+		}
+
+		@Override
+		public boolean isResourceBlank() {
+			return getResource().isBlank();
+		}
+
+		@Override
+		public ItemVariant getResource() {
+			return BONE_MEAL;
+		}
+
+		@Override
+		public long getAmount() {
+			return hasBoneMeal() ? 1 : 0;
+		}
+
+		@Override
+		public long getCapacity() {
+			return 1;
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/CursorSlotWrapper.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/CursorSlotWrapper.java
new file mode 100644
index 000000000..d2609b8a8
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/CursorSlotWrapper.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.Map;
+
+import com.google.common.collect.MapMaker;
+
+import net.minecraft.item.ItemStack;
+import net.minecraft.screen.ScreenHandler;
+
+import net.fabricmc.fabric.api.transfer.v1.item.base.SingleStackStorage;
+
+/**
+ * Wrapper around the cursor slot of a screen handler.
+ */
+public class CursorSlotWrapper extends SingleStackStorage {
+	private static final Map<ScreenHandler, CursorSlotWrapper> WRAPPERS = new MapMaker().weakValues().makeMap();
+
+	public static CursorSlotWrapper get(ScreenHandler screenHandler) {
+		return WRAPPERS.computeIfAbsent(screenHandler, CursorSlotWrapper::new);
+	}
+
+	private final ScreenHandler screenHandler;
+
+	private CursorSlotWrapper(ScreenHandler screenHandler) {
+		this.screenHandler = screenHandler;
+	}
+
+	@Override
+	protected ItemStack getStack() {
+		return screenHandler.getCursorStack();
+	}
+
+	@Override
+	protected void setStack(ItemStack stack) {
+		screenHandler.setCursorStack(stack);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventorySlotWrapper.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventorySlotWrapper.java
new file mode 100644
index 000000000..2d46f0b98
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventorySlotWrapper.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import net.minecraft.item.ItemStack;
+
+import net.fabricmc.fabric.api.transfer.v1.item.base.SingleStackStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+/**
+ * A wrapper around a single slot of an inventory.
+ * We must ensure that only one instance of this class exists for every inventory slot,
+ * or the transaction logic will not work correctly.
+ * This is handled by the Map in InventoryStorageImpl.
+ */
+class InventorySlotWrapper extends SingleStackStorage {
+	/**
+	 * The strong reference to the InventoryStorageImpl ensures that the weak value doesn't get GC'ed when individual slots are still being accessed.
+	 */
+	private final InventoryStorageImpl storage;
+	final int slot;
+
+	InventorySlotWrapper(InventoryStorageImpl storage, int slot) {
+		this.storage = storage;
+		this.slot = slot;
+	}
+
+	@Override
+	protected ItemStack getStack() {
+		return storage.inventory.getStack(slot);
+	}
+
+	@Override
+	protected void setStack(ItemStack stack) {
+		storage.inventory.setStack(slot, stack);
+	}
+
+	@Override
+	protected boolean canInsert(ItemVariant itemVariant) {
+		return storage.inventory.isValid(slot, itemVariant.toStack());
+	}
+
+	@Override
+	public int getCapacity(ItemVariant variant) {
+		return Math.min(storage.inventory.getMaxCountPerStack(), variant.getItem().getMaxCount());
+	}
+
+	// We override updateSnapshots to also schedule a markDirty call for the backing inventory.
+	@Override
+	public void updateSnapshots(TransactionContext transaction) {
+		storage.markDirtyParticipant.updateSnapshots(transaction);
+		super.updateSnapshots(transaction);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventoryStorageImpl.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventoryStorageImpl.java
new file mode 100644
index 000000000..e569a2207
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/InventoryStorageImpl.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import com.google.common.collect.MapMaker;
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.inventory.Inventory;
+import net.minecraft.inventory.SidedInventory;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
+
+/**
+ * Implementation of {@link InventoryStorage}.
+ * Note on thread-safety: we assume that Inventory's are inherently single-threaded, and no attempt is made at synchronization.
+ * However, the access to implementations can happen on multiple threads concurrently, which is why we use a thread-safe wrapper map.
+ */
+public class InventoryStorageImpl extends CombinedStorage<ItemVariant, SingleSlotStorage<ItemVariant>> implements InventoryStorage {
+	/**
+	 * Global wrapper concurrent map.
+	 *
+	 * <p>A note on GC: weak keys alone are not suitable as the InventoryStorage slots strongly reference the Inventory keys.
+	 * Weak values are suitable, but we have to ensure that the InventoryStorageImpl remains strongly reachable as long as
+	 * one of the slot wrappers refers to it, hence the {@code strongRef} field in {@link InventorySlotWrapper}.
+	 */
+	// TODO: look into promoting the weak reference to a soft reference if building the wrappers becomes a performance bottleneck.
+	// TODO: should have identity semantics?
+	private static final Map<Inventory, InventoryStorageImpl> WRAPPERS = new MapMaker().weakValues().makeMap();
+
+	public static InventoryStorage of(Inventory inventory, @Nullable Direction direction) {
+		InventoryStorageImpl storage = WRAPPERS.computeIfAbsent(inventory, inv -> {
+			if (inv instanceof PlayerInventory playerInventory) {
+				return new PlayerInventoryStorageImpl(playerInventory);
+			} else {
+				return new InventoryStorageImpl(inv);
+			}
+		});
+		storage.resizeSlotList();
+		return storage.getSidedWrapper(direction);
+	}
+
+	final Inventory inventory;
+	/**
+	 * This {@code backingList} is the real list of wrappers.
+	 * The {@code parts} in the superclass is the public-facing unmodifiable sublist with exactly the right amount of slots.
+	 */
+	final List<InventorySlotWrapper> backingList;
+	/**
+	 * This participant ensures that markDirty is only called once for the entire inventory.
+	 */
+	final MarkDirtyParticipant markDirtyParticipant = new MarkDirtyParticipant();
+
+	InventoryStorageImpl(Inventory inventory) {
+		super(Collections.emptyList());
+		this.inventory = inventory;
+		this.backingList = new ArrayList<>();
+	}
+
+	@Override
+	public List<SingleSlotStorage<ItemVariant>> getSlots() {
+		return parts;
+	}
+
+	/**
+	 * Resize slot list to match the current size of the inventory.
+	 */
+	private void resizeSlotList() {
+		int inventorySize = inventory.size();
+
+		// If the public-facing list must change...
+		if (inventorySize != parts.size()) {
+			// Ensure we have enough wrappers in the backing list.
+			while (backingList.size() < inventorySize) {
+				backingList.add(new InventorySlotWrapper(this, backingList.size()));
+			}
+
+			// Update the public-facing list.
+			parts = Collections.unmodifiableList(backingList.subList(0, inventorySize));
+		}
+	}
+
+	private InventoryStorage getSidedWrapper(@Nullable Direction direction) {
+		if (inventory instanceof SidedInventory && direction != null) {
+			return new SidedInventoryStorageImpl(this, direction);
+		} else {
+			return this;
+		}
+	}
+
+	// Boolean is used to prevent allocation. Null values are not allowed by SnapshotParticipant.
+	class MarkDirtyParticipant extends SnapshotParticipant<Boolean> {
+		@Override
+		protected Boolean createSnapshot() {
+			return Boolean.TRUE;
+		}
+
+		@Override
+		protected void readSnapshot(Boolean snapshot) {
+		}
+
+		@Override
+		protected void onFinalCommit() {
+			inventory.markDirty();
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantCache.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantCache.java
new file mode 100644
index 000000000..fef26b1f5
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantCache.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+
+/**
+ * Implemented by items to cache the ItemVariant with a null tag inside the Item object directly.
+ */
+public interface ItemVariantCache {
+	ItemVariant fabric_getCachedItemVariant();
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantImpl.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantImpl.java
new file mode 100644
index 000000000..2b8049ad9
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/ItemVariantImpl.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.Objects;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.item.Item;
+import net.minecraft.item.Items;
+import net.minecraft.nbt.NbtCompound;
+import net.minecraft.network.PacketByteBuf;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.registry.Registry;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+
+public class ItemVariantImpl implements ItemVariant {
+	public static ItemVariant of(Item item, @Nullable NbtCompound tag) {
+		Objects.requireNonNull(item, "Item may not be null.");
+
+		// Only tag-less or empty item variants are cached for now.
+		if (tag == null || item == Items.AIR) {
+			return ((ItemVariantCache) item).fabric_getCachedItemVariant();
+		} else {
+			return new ItemVariantImpl(item, tag);
+		}
+	}
+
+	private static final Logger LOGGER = LogManager.getLogger("fabric-transfer-api-v1/item");
+
+	private final Item item;
+	private final @Nullable NbtCompound nbt;
+	private final int hashCode;
+
+	public ItemVariantImpl(Item item, NbtCompound nbt) {
+		this.item = item;
+		this.nbt = nbt == null ? null : nbt.copy(); // defensive copy
+		hashCode = Objects.hash(item, nbt);
+	}
+
+	@Override
+	public Item getObject() {
+		return item;
+	}
+
+	@Nullable
+	@Override
+	public NbtCompound getNbt() {
+		return nbt;
+	}
+
+	@Override
+	public boolean isBlank() {
+		return item == Items.AIR;
+	}
+
+	@Override
+	public NbtCompound toNbt() {
+		NbtCompound result = new NbtCompound();
+		result.putString("item", Registry.ITEM.getId(item).toString());
+
+		if (nbt != null) {
+			result.put("tag", nbt.copy());
+		}
+
+		return result;
+	}
+
+	public static ItemVariant fromNbt(NbtCompound tag) {
+		try {
+			Item item = Registry.ITEM.get(new Identifier(tag.getString("item")));
+			NbtCompound aTag = tag.contains("tag") ? tag.getCompound("tag") : null;
+			return of(item, aTag);
+		} catch (RuntimeException runtimeException) {
+			LOGGER.debug("Tried to load an invalid ItemVariant from NBT: {}", tag, runtimeException);
+			return ItemVariant.blank();
+		}
+	}
+
+	@Override
+	public void toPacket(PacketByteBuf buf) {
+		if (isBlank()) {
+			buf.writeBoolean(false);
+		} else {
+			buf.writeBoolean(true);
+			buf.writeVarInt(Item.getRawId(item));
+			buf.writeNbt(nbt);
+		}
+	}
+
+	public static ItemVariant fromPacket(PacketByteBuf buf) {
+		if (!buf.readBoolean()) {
+			return ItemVariant.blank();
+		} else {
+			Item item = Item.byRawId(buf.readVarInt());
+			NbtCompound nbt = buf.readNbt();
+			return of(item, nbt);
+		}
+	}
+
+	@Override
+	public String toString() {
+		return "ItemVariantImpl{item=" + item + ", tag=" + nbt + '}';
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		// succeed fast with == check
+		if (this == o) return true;
+		if (o == null || getClass() != o.getClass()) return false;
+
+		ItemVariantImpl ItemVariant = (ItemVariantImpl) o;
+		// fail fast with hash code
+		return hashCode == ItemVariant.hashCode && item == ItemVariant.item && nbtMatches(ItemVariant.nbt);
+	}
+
+	@Override
+	public int hashCode() {
+		return hashCode;
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/PlayerInventoryStorageImpl.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/PlayerInventoryStorageImpl.java
new file mode 100644
index 000000000..fe4ac3faa
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/PlayerInventoryStorageImpl.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.entity.player.PlayerInventory;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.PlayerInventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant;
+
+class PlayerInventoryStorageImpl extends InventoryStorageImpl implements PlayerInventoryStorage {
+	private final DroppedStacks droppedStacks;
+	private final PlayerEntity player;
+
+	PlayerInventoryStorageImpl(PlayerInventory playerInventory) {
+		super(playerInventory);
+		this.droppedStacks = new DroppedStacks();
+		this.player = playerInventory.player;
+	}
+
+	@Override
+	public void offerOrDrop(ItemVariant resource, long amount, TransactionContext tx) {
+		StoragePreconditions.notBlankNotNegative(resource, amount);
+
+		List<SingleSlotStorage<ItemVariant>> mainSlots = getSlots().subList(0, PlayerInventory.MAIN_SIZE);
+
+		// Stack into the main stack first
+		SingleSlotStorage<ItemVariant> selectedSlot = getSlots().get(player.getInventory().selectedSlot);
+
+		if (selectedSlot.getResource().equals(resource)) {
+			amount -= selectedSlot.insert(resource, amount, tx);
+		}
+
+		// Stack into the offhand stack otherwise
+		SingleSlotStorage<ItemVariant> offHandSlot = getSlots().get(PlayerInventory.OFF_HAND_SLOT);
+
+		if (offHandSlot.getResource().equals(resource)) {
+			amount -= offHandSlot.insert(resource, amount, tx);
+		}
+
+		// Otherwise insert into the main slots, first iteration tries to stack, second iteration inserts into empty slots.
+		for (int iteration = 0; iteration < 2; iteration++) {
+			boolean allowEmptySlots = iteration == 1;
+
+			for (SingleSlotStorage<ItemVariant> slot : mainSlots) {
+				if (!slot.isResourceBlank() || allowEmptySlots) {
+					amount -= slot.insert(resource, amount, tx);
+				}
+			}
+		}
+
+		// Drop leftover in the world on the server side (will be synced by the game with the client).
+		// Dropping items is server-side only because it involves randomness.
+		if (amount > 0 && player.world.isClient()) {
+			droppedStacks.addDrop(resource, amount, tx);
+		}
+	}
+
+	private class DroppedStacks extends SnapshotParticipant<Integer> {
+		final List<ItemVariant> droppedKeys = new ArrayList<>();
+		final List<Long> droppedCounts = new ArrayList<>();
+
+		void addDrop(ItemVariant key, long count, TransactionContext transaction) {
+			updateSnapshots(transaction);
+			droppedKeys.add(key);
+			droppedCounts.add(count);
+		}
+
+		@Override
+		protected Integer createSnapshot() {
+			return droppedKeys.size();
+		}
+
+		@Override
+		protected void readSnapshot(Integer snapshot) {
+			// effectively cancel dropping the stacks
+			int previousSize = snapshot;
+
+			while (droppedKeys.size() > previousSize) {
+				droppedKeys.remove(droppedKeys.size() - 1);
+				droppedCounts.remove(droppedCounts.size() - 1);
+			}
+		}
+
+		@Override
+		protected void onFinalCommit() {
+			// actually drop the stacks
+			for (int i = 0; i < droppedKeys.size(); ++i) {
+				ItemVariant key = droppedKeys.get(i);
+
+				while (droppedCounts.get(i) > 0) {
+					int dropped = (int) Math.min(key.getItem().getMaxCount(), droppedCounts.get(i));
+					player.dropStack(key.toStack(dropped));
+					droppedCounts.set(i, droppedCounts.get(i) - dropped);
+				}
+			}
+
+			droppedKeys.clear();
+			droppedCounts.clear();
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventorySlotWrapper.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventorySlotWrapper.java
new file mode 100644
index 000000000..f3a9f5d49
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventorySlotWrapper.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import net.minecraft.inventory.SidedInventory;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+/**
+ * Wrapper around an {@link InventorySlotWrapper}, with additional canInsert and canExtract checks.
+ */
+class SidedInventorySlotWrapper implements SingleSlotStorage<ItemVariant> {
+	private final InventorySlotWrapper slotWrapper;
+	private final SidedInventory sidedInventory;
+	private final Direction direction;
+
+	SidedInventorySlotWrapper(InventorySlotWrapper slotWrapper, SidedInventory sidedInventory, Direction direction) {
+		this.slotWrapper = slotWrapper;
+		this.sidedInventory = sidedInventory;
+		this.direction = direction;
+	}
+
+	@Override
+	public long insert(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+		if (!sidedInventory.canInsert(slotWrapper.slot, resource.toStack(), direction)) {
+			return 0;
+		} else {
+			return slotWrapper.insert(resource, maxAmount, transaction);
+		}
+	}
+
+	@Override
+	public long extract(ItemVariant resource, long maxAmount, TransactionContext transaction) {
+		if (!sidedInventory.canExtract(slotWrapper.slot, resource.toStack(), direction)) {
+			return 0;
+		} else {
+			return slotWrapper.extract(resource, maxAmount, transaction);
+		}
+	}
+
+	@Override
+	public boolean isResourceBlank() {
+		return slotWrapper.isResourceBlank();
+	}
+
+	@Override
+	public ItemVariant getResource() {
+		return slotWrapper.getResource();
+	}
+
+	@Override
+	public long getAmount() {
+		return slotWrapper.getAmount();
+	}
+
+	@Override
+	public long getCapacity() {
+		return slotWrapper.getCapacity();
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventoryStorageImpl.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventoryStorageImpl.java
new file mode 100644
index 000000000..9519b40ed
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/impl/transfer/item/SidedInventoryStorageImpl.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.transfer.item;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import net.minecraft.inventory.SidedInventory;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+
+/**
+ * Sidedness-aware wrapper around a {@link InventoryStorageImpl} for sided inventories.
+ */
+class SidedInventoryStorageImpl extends CombinedStorage<ItemVariant, SingleSlotStorage<ItemVariant>> implements InventoryStorage {
+	SidedInventoryStorageImpl(InventoryStorageImpl storage, Direction direction) {
+		super(Collections.unmodifiableList(createWrapperList(storage, direction)));
+	}
+
+	@Override
+	public List<SingleSlotStorage<ItemVariant>> getSlots() {
+		return parts;
+	}
+
+	private static List<SingleSlotStorage<ItemVariant>> createWrapperList(InventoryStorageImpl storage, Direction direction) {
+		SidedInventory inventory = (SidedInventory) storage.inventory;
+		int[] availableSlots = inventory.getAvailableSlots(direction);
+		SidedInventorySlotWrapper[] slots = new SidedInventorySlotWrapper[availableSlots.length];
+
+		for (int i = 0; i < availableSlots.length; ++i) {
+			slots[i] = new SidedInventorySlotWrapper(storage.backingList.get(availableSlots[i]), inventory, direction);
+		}
+
+		return Arrays.asList(slots);
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BucketItemAccessor.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BucketItemAccessor.java
new file mode 100644
index 000000000..2a81f3e57
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/BucketItemAccessor.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+import net.minecraft.fluid.Fluid;
+import net.minecraft.item.BucketItem;
+
+@Mixin(BucketItem.class)
+public interface BucketItemAccessor {
+	@Accessor("fluid")
+	Fluid fabric_getFluid();
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DoubleInventoryAccessor.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DoubleInventoryAccessor.java
new file mode 100644
index 000000000..43711f25d
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DoubleInventoryAccessor.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+import net.minecraft.inventory.DoubleInventory;
+import net.minecraft.inventory.Inventory;
+
+@Mixin(DoubleInventory.class)
+public interface DoubleInventoryAccessor {
+	@Accessor("first")
+	Inventory fabric_getFirst();
+
+	@Accessor("second")
+	Inventory fabric_getSecond();
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java
new file mode 100644
index 000000000..a5c94c200
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/DropperBlockMixin.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.LocalCapture;
+
+import net.minecraft.block.DropperBlock;
+import net.minecraft.block.entity.DispenserBlockEntity;
+import net.minecraft.item.ItemStack;
+import net.minecraft.server.world.ServerWorld;
+import net.minecraft.util.math.BlockPointerImpl;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil;
+
+/**
+ * Allows droppers to insert into ItemVariant storages.
+ */
+@Mixin(DropperBlock.class)
+public class DropperBlockMixin {
+	@Inject(
+			at = @At(
+					value = "INVOKE",
+					target = "Lnet/minecraft/util/math/BlockPos;offset(Lnet/minecraft/util/math/Direction;)Lnet/minecraft/util/math/BlockPos;"
+			),
+			method = "dispense",
+			locals = LocalCapture.CAPTURE_FAILHARD,
+			cancellable = true,
+			allow = 1
+	)
+	public void hookDispense(ServerWorld world, BlockPos pos, CallbackInfo ci, BlockPointerImpl blockPointerImpl, DispenserBlockEntity dispenser, int slot, ItemStack stack, Direction direction) {
+		Storage<ItemVariant> target = ItemStorage.SIDED.find(world, pos.offset(direction), direction.getOpposite());
+
+		if (target != null) {
+			Storage<ItemVariant> source = InventoryStorage.of(dispenser, null).getSlots().get(slot);
+
+			if (StorageUtil.move(source, target, k -> true, 1, null) == 1) {
+				ci.cancel();
+			}
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java
new file mode 100644
index 000000000..a4784fbb8
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityAccessor.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+import net.minecraft.block.entity.HopperBlockEntity;
+import net.minecraft.inventory.Inventory;
+
+/**
+ * Hopper accessors, for use in {@link HopperBlockEntityMixin}.
+ */
+@Mixin(HopperBlockEntity.class)
+public interface HopperBlockEntityAccessor extends Inventory {
+	@Invoker("setCooldown")
+	void fabric_callSetCooldown(int cooldown);
+
+	@Accessor("lastTickTime")
+	long fabric_getLastTickTime();
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java
new file mode 100644
index 000000000..c48a3cc75
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/HopperBlockEntityMixin.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.jetbrains.annotations.Nullable;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import net.minecraft.block.BlockState;
+import net.minecraft.block.HopperBlock;
+import net.minecraft.block.entity.BlockEntity;
+import net.minecraft.block.entity.Hopper;
+import net.minecraft.block.entity.HopperBlockEntity;
+import net.minecraft.inventory.Inventory;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Direction;
+import net.minecraft.world.World;
+
+import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil;
+
+/**
+ * Allows hoppers to interact with ItemVariant storages.
+ */
+@Mixin(HopperBlockEntity.class)
+public class HopperBlockEntityMixin {
+	@Inject(
+			at = @At("HEAD"),
+			method = "insert(Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/block/BlockState;Lnet/minecraft/inventory/Inventory;)Z",
+			cancellable = true
+	)
+	private static void hookInsert(World world, BlockPos pos, BlockState state, Inventory inventory, CallbackInfoReturnable<Boolean> cir) {
+		Direction direction = state.get(HopperBlock.FACING);
+		BlockPos targetPos = pos.offset(direction);
+		BlockEntity targetBe = world.getBlockEntity(targetPos);
+		Storage<ItemVariant> target = ItemStorage.SIDED.find(world, targetPos, null, targetBe, direction.getOpposite());
+
+		if (target != null) {
+			cir.setReturnValue(doTransfer(InventoryStorage.of(inventory, direction), target, inventory, targetBe));
+		}
+	}
+
+	@Inject(
+			at = @At("HEAD"),
+			method = "extract(Lnet/minecraft/world/World;Lnet/minecraft/block/entity/Hopper;)Z",
+			cancellable = true
+	)
+	private static void hookExtract(World world, Hopper hopper, CallbackInfoReturnable<Boolean> cir) {
+		BlockPos sourcePos = new BlockPos(hopper.getHopperX(), hopper.getHopperY() + 1.0D, hopper.getHopperZ());
+		BlockEntity sourceBe = world.getBlockEntity(sourcePos);
+		Storage<ItemVariant> source = ItemStorage.SIDED.find(world, sourcePos, null, sourceBe, Direction.DOWN);
+
+		if (source != null) {
+			cir.setReturnValue(doTransfer(source, InventoryStorage.of(hopper, Direction.UP), sourceBe, hopper));
+		}
+	}
+
+	private static boolean doTransfer(Storage<ItemVariant> from, Storage<ItemVariant> to, @Nullable Object invFrom, @Nullable Object invTo) {
+		if (invFrom instanceof HopperBlockEntityAccessor hopperFrom && invTo instanceof HopperBlockEntityAccessor hopperTo) {
+			// Hoppers have some special interactions (see HopperBlockEntity#transfer)
+			boolean wasEmpty = hopperTo.isEmpty();
+			boolean moved = StorageUtil.move(from, to, k -> true, 1, null) == 1;
+
+			if (moved && wasEmpty && hopperTo.fabric_getLastTickTime() >= hopperFrom.fabric_getLastTickTime()) {
+				hopperTo.fabric_callSetCooldown(7);
+			}
+
+			return moved;
+		} else {
+			return StorageUtil.move(from, to, k -> true, 1, null) == 1;
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ItemMixin.java b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ItemMixin.java
new file mode 100644
index 000000000..c05715ad3
--- /dev/null
+++ b/fabric-transfer-api-v1/src/main/java/net/fabricmc/fabric/mixin/transfer/ItemMixin.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.transfer;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+
+import net.minecraft.item.Item;
+
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.impl.transfer.item.ItemVariantCache;
+import net.fabricmc.fabric.impl.transfer.item.ItemVariantImpl;
+
+/**
+ * Cache the ItemVariant with a null tag inside each Item directly.
+ */
+@Mixin(Item.class)
+public class ItemMixin implements ItemVariantCache {
+	@Unique
+	private final ItemVariant cachedItemVariant = new ItemVariantImpl((Item) (Object) this, null);
+
+	@Override
+	public ItemVariant fabric_getCachedItemVariant() {
+		return cachedItemVariant;
+	}
+}
diff --git a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1/icon.png b/fabric-transfer-api-v1/src/main/resources/assets/fabric-transfer-api-v1/icon.png
similarity index 100%
rename from fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1/icon.png
rename to fabric-transfer-api-v1/src/main/resources/assets/fabric-transfer-api-v1/icon.png
diff --git a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json b/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
index 5ead6181e..becd80534 100644
--- a/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
+++ b/fabric-transfer-api-v1/src/main/resources/fabric-transfer-api-v1.mixins.json
@@ -3,6 +3,12 @@
   "package": "net.fabricmc.fabric.mixin.transfer",
   "compatibilityLevel": "JAVA_8",
   "mixins": [
-    "FluidMixin"
+    "BucketItemAccessor",
+    "DoubleInventoryAccessor",
+    "DropperBlockMixin",
+    "FluidMixin",
+    "HopperBlockEntityAccessor",
+    "HopperBlockEntityMixin",
+    "ItemMixin"
   ]
 }
diff --git a/fabric-transfer-api-v1/src/main/resources/fabric.mod.json b/fabric-transfer-api-v1/src/main/resources/fabric.mod.json
index 5e85b90b8..5fb9be279 100644
--- a/fabric-transfer-api-v1/src/main/resources/fabric.mod.json
+++ b/fabric-transfer-api-v1/src/main/resources/fabric.mod.json
@@ -5,7 +5,7 @@
   "version": "${version}",
   "environment": "*",
   "license": "Apache-2.0",
-  "icon": "assets/fabric-api-lookup-api-v1/icon.png",
+  "icon": "assets/fabric-transfer-api-v1/icon.png",
   "contact": {
     "homepage": "https://fabricmc.net",
     "irc": "irc://irc.esper.net:6667/fabric",
@@ -20,7 +20,7 @@
     "fabric-api-lookup-api-v1": "*",
     "fabric-rendering-fluids-v1": "*"
   },
-  "description": "A common API for the transfer of fluids and other game resources.",
+  "description": "A common API for the transfer of fluids, items and other game resources.",
   "mixins": [
     "fabric-transfer-api-v1.mixins.json"
   ],
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java
new file mode 100644
index 000000000..55687ea3b
--- /dev/null
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidItemTests.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.transfer.fluid;
+
+import static net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants.BOTTLE;
+import static net.fabricmc.fabric.api.transfer.v1.fluid.FluidConstants.BUCKET;
+
+import java.util.List;
+import java.util.Objects;
+
+import net.minecraft.fluid.Fluids;
+import net.minecraft.inventory.Inventory;
+import net.minecraft.inventory.SimpleInventory;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.potion.PotionUtil;
+import net.minecraft.potion.Potions;
+
+import net.fabricmc.fabric.api.transfer.v1.context.ContainerItemContext;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage;
+import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
+import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.ResourceAmount;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
+import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext;
+
+public class FluidItemTests {
+	public static void run() {
+		testFluidItemApi();
+		testWaterPotion();
+		testSimpleContentsQuery();
+	}
+
+	private static void testFluidItemApi() {
+		FluidVariant water = FluidVariant.of(Fluids.WATER);
+		ItemVariant waterBucket = ItemVariant.of(Items.WATER_BUCKET);
+		Inventory testInventory = new FluidItemTestInventory(ItemStack.EMPTY, new ItemStack(Items.BUCKET), new ItemStack(Items.WATER_BUCKET));
+
+		Storage<FluidVariant> slot1Storage = new InventoryContainerItem(testInventory, 1).find(FluidStorage.ITEM);
+		Storage<FluidVariant> slot2Storage = new InventoryContainerItem(testInventory, 2).find(FluidStorage.ITEM);
+
+		if (slot1Storage == null || slot2Storage == null) throw new AssertionError("We should have provided a fluid storage for buckets.");
+
+		try (Transaction transaction = Transaction.openOuter()) {
+			// Test extract.
+			if (slot2Storage.extract(water, BUCKET, transaction) != BUCKET) throw new AssertionError("Should have extracted from full bucket.");
+			// Test that an empty bucket was added.
+			if (!stackEquals(testInventory.getStack(1), Items.BUCKET, 2)) throw new AssertionError("Buckets should have stacked.");
+			// Test that we can't extract again
+			if (slot2Storage.extract(water, BUCKET, transaction) != 0) throw new AssertionError("Should not have extracted a second time.");
+			// Now insert water into slot 1.
+			if (slot1Storage.insert(water, BUCKET, transaction) != BUCKET) throw new AssertionError("Failed to insert.");
+			// Check that it filled slot 0.
+			if (!stackEquals(testInventory.getStack(0), Items.WATER_BUCKET, 1)) throw new AssertionError("Should have filled slot 0.");
+			// Now we yeet the bucket just because we can.
+			SingleSlotStorage<ItemVariant> slot0 = InventoryStorage.of(testInventory, null).getSlots().get(0);
+			if (slot0.extract(waterBucket, 1, transaction) != 1) throw new AssertionError("Failed to yeet bucket.");
+			// Now insert should fill slot 1 with a bucket.
+			if (slot1Storage.insert(water, BUCKET, transaction) != BUCKET) throw new AssertionError("Failed to insert.");
+			// Check inventory contents.
+			if (!testInventory.getStack(0).isEmpty()) throw new AssertionError("Slot 0 should have been empty.");
+			if (!stackEquals(testInventory.getStack(1), Items.WATER_BUCKET, 1)) throw new AssertionError("Should have filled slot 1 with a water bucket.");
+		}
+
+		// Check contents after abort
+		if (!testInventory.getStack(0).isEmpty()) throw new AssertionError("Failed to abort slot 0.");
+		if (!stackEquals(testInventory.getStack(1), Items.BUCKET, 1)) throw new AssertionError("Failed to abort slot 1.");
+		if (!stackEquals(testInventory.getStack(2), Items.WATER_BUCKET, 1)) throw new AssertionError("Failed to abort slot 2.");
+	}
+
+	private static boolean stackEquals(ItemStack stack, Item item, int count) {
+		return stack.getItem() == item && stack.getCount() == count;
+	}
+
+	private static class FluidItemTestInventory extends SimpleInventory {
+		FluidItemTestInventory(ItemStack... stacks) {
+			super(stacks);
+		}
+
+		@Override
+		public boolean isValid(int slot, ItemStack stack) {
+			return slot != 2; // Forbid insertion into slot 2.
+		}
+	}
+
+	private static class InventoryContainerItem implements ContainerItemContext {
+		private final InventoryStorage inventory;
+		private final SingleSlotStorage<ItemVariant> slot;
+
+		InventoryContainerItem(Inventory inv, int slotIndex) {
+			this.inventory = InventoryStorage.of(inv, null);
+			this.slot = inventory.getSlots().get(slotIndex);
+		}
+
+		@Override
+		public SingleSlotStorage<ItemVariant> getMainSlot() {
+			return slot;
+		}
+
+		@Override
+		public long insertOverflow(ItemVariant itemVariant, long maxAmount, TransactionContext transactionContext) {
+			long inserted = 0;
+
+			// Try to be smart and stack first!
+			for (SingleSlotStorage<ItemVariant> slot : inventory.getSlots()) {
+				if (slot.getResource().equals(itemVariant)) {
+					inserted += slot.insert(itemVariant, maxAmount - inserted, transactionContext);
+				}
+			}
+
+			return inserted + inventory.insert(itemVariant, maxAmount - inserted, transactionContext);
+		}
+
+		@Override
+		public List<SingleSlotStorage<ItemVariant>> getAdditionalSlots() {
+			return inventory.getSlots();
+		}
+	}
+
+	private static void testWaterPotion() {
+		FluidVariant water = FluidVariant.of(Fluids.WATER);
+		Inventory testInventory = new SimpleInventory(new ItemStack(Items.GLASS_BOTTLE));
+
+		// Try to fill empty potion
+		Storage<FluidVariant> emptyBottleStorage = new InventoryContainerItem(testInventory, 0).find(FluidStorage.ITEM);
+
+		try (Transaction transaction = Transaction.openOuter()) {
+			if (emptyBottleStorage.insert(water, Long.MAX_VALUE, transaction) != BOTTLE) throw new AssertionError("Failed to insert.");
+			transaction.commit();
+		}
+
+		if (PotionUtil.getPotion(testInventory.getStack(0)) != Potions.WATER) throw new AssertionError("Expected water potion.");
+
+		// Try to empty from water potion
+		Storage<FluidVariant> waterBottleStroage = new InventoryContainerItem(testInventory, 0).find(FluidStorage.ITEM);
+
+		try (Transaction transaction = Transaction.openOuter()) {
+			if (waterBottleStroage.extract(water, Long.MAX_VALUE, transaction) != BOTTLE) throw new AssertionError("Failed to extract.");
+			transaction.commit();
+		}
+
+		// Make sure extraction nothing is returned for other potions
+		PotionUtil.setPotion(testInventory.getStack(0), Potions.LUCK);
+		Storage<FluidVariant> luckyStorage = new InventoryContainerItem(testInventory, 0).find(FluidStorage.ITEM);
+
+		if (StorageUtil.findStoredResource(luckyStorage, null) != null) {
+			throw new AssertionError("Found a resource in an unhandled potion.");
+		}
+	}
+
+	private static void testSimpleContentsQuery() {
+		assertEquals(
+				new ResourceAmount<>(FluidVariant.of(Fluids.WATER), BUCKET),
+				StorageUtil.findExtractableContent(
+						ContainerItemContext.withInitial(new ItemStack(Items.WATER_BUCKET)).find(FluidStorage.ITEM),
+						null
+				)
+		);
+	}
+
+	private static void assertEquals(Object expected, Object actual) {
+		if (!Objects.equals(expected, actual)) {
+			throw new AssertionError(String.format("assertEquals failed%nexpected: %s%n but was: %s", expected, actual));
+		}
+	}
+}
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java
index 813f40938..8675d8cf8 100644
--- a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/FluidTransferTest.java
@@ -34,7 +34,8 @@ import net.fabricmc.api.ModInitializer;
 import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
 import net.fabricmc.fabric.api.transfer.v1.fluid.FluidVariant;
 import net.fabricmc.fabric.api.transfer.v1.fluid.FluidStorage;
-import net.fabricmc.fabric.api.transfer.v1.fluid.base.SingleFluidStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage;
+import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleVariantStorage;
 import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
 
 public class FluidTransferTest implements ModInitializer {
@@ -61,6 +62,8 @@ public class FluidTransferTest implements ModInitializer {
 
 		testFluidStorage();
 		testTransactionExceptions();
+		ItemTests.run();
+		FluidItemTests.run();
 	}
 
 	private static void registerBlock(Block block, String name) {
@@ -70,10 +73,15 @@ public class FluidTransferTest implements ModInitializer {
 	}
 
 	private static final FluidVariant TAGGED_WATER, TAGGED_WATER_2, WATER, LAVA;
-	private static int markDirtyCount = 0;
+	private static int finalCommitCount = 0;
+
+	private static SingleSlotStorage<FluidVariant> createWaterStorage() {
+		return new SingleVariantStorage<>() {
+			@Override
+			protected FluidVariant getBlankVariant() {
+				return FluidVariant.blank();
+			}
 
-	private static SingleFluidStorage createWaterStorage() {
-		return new SingleFluidStorage() {
 			@Override
 			protected long getCapacity(FluidVariant fluidVariant) {
 				return BUCKET * 2;
@@ -85,8 +93,8 @@ public class FluidTransferTest implements ModInitializer {
 			}
 
 			@Override
-			protected void markDirty() {
-				markDirtyCount++;
+			protected void onFinalCommit() {
+				finalCommitCount++;
 			}
 		};
 	}
@@ -101,7 +109,7 @@ public class FluidTransferTest implements ModInitializer {
 	}
 
 	private static void testFluidStorage() {
-		SingleFluidStorage waterStorage = createWaterStorage();
+		SingleSlotStorage<FluidVariant> waterStorage = createWaterStorage();
 
 		// Test content
 		if (!waterStorage.isResourceBlank()) throw new AssertionError("Should have been blank");
@@ -156,15 +164,15 @@ public class FluidTransferTest implements ModInitializer {
 		// Without outer commit
 		insertWaterWithNesting(waterStorage, false);
 		if (waterStorage.getAmount() != 0) throw new AssertionError("Amount should have been reverted to zero");
-		if (markDirtyCount != 0) throw new AssertionError("Nothing should have called markDirty() yet (no outer commit)");
+		if (finalCommitCount != 0) throw new AssertionError("Nothing should have called onFinalCommit() yet (no outer commit)");
 
 		// With outer commit
 		insertWaterWithNesting(waterStorage, true);
 		if (waterStorage.getAmount() != 2 * BUCKET) throw new AssertionError("Outer was committed, so we should still have two buckets");
-		if (markDirtyCount != 1) throw new AssertionError("markDirty() should have been called exactyl once.");
+		if (finalCommitCount != 1) throw new AssertionError("onFinalCommit() should have been called exactly once.");
 	}
 
-	private static void insertWaterWithNesting(SingleFluidStorage waterStorage, boolean doOuterCommit) {
+	private static void insertWaterWithNesting(SingleSlotStorage<FluidVariant> waterStorage, boolean doOuterCommit) {
 		try (Transaction tx = Transaction.openOuter()) {
 			if (waterStorage.getAmount() != 0) throw new AssertionError("Initial amount is wrong");
 			if (waterStorage.insert(WATER, BUCKET, tx) != BUCKET) throw new AssertionError("Water insertion failed");
diff --git a/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java
new file mode 100644
index 000000000..b5d817acc
--- /dev/null
+++ b/fabric-transfer-api-v1/src/testmod/java/net/fabricmc/fabric/test/transfer/fluid/ItemTests.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.transfer.fluid;
+
+import java.util.stream.IntStream;
+
+import org.jetbrains.annotations.Nullable;
+
+import net.minecraft.inventory.Inventory;
+import net.minecraft.inventory.SidedInventory;
+import net.minecraft.inventory.SimpleInventory;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.screen.ScreenHandler;
+import net.minecraft.util.math.Direction;
+
+import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage;
+import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant;
+import net.fabricmc.fabric.api.transfer.v1.storage.Storage;
+import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil;
+import net.fabricmc.fabric.api.transfer.v1.transaction.Transaction;
+
+/**
+ * Tests for the item transfer APIs.
+ */
+public class ItemTests {
+	public static void run() {
+		testInventoryWrappers();
+		testLimitedStackCountInventory();
+		testLimitedStackCountItem();
+	}
+
+	private static void testInventoryWrappers() {
+		ItemVariant emptyBucket = ItemVariant.of(Items.BUCKET);
+		TestSidedInventory testInventory = new TestSidedInventory();
+		checkComparatorOutput(testInventory, null);
+
+		// Create a few wrappers.
+		InventoryStorage unsidedWrapper = InventoryStorage.of(testInventory, null);
+		InventoryStorage downWrapper = InventoryStorage.of(testInventory, Direction.DOWN);
+		InventoryStorage upWrapper = InventoryStorage.of(testInventory, Direction.UP);
+
+		// Make sure querying a new wrapper returns the same one.
+		if (InventoryStorage.of(testInventory, null) != unsidedWrapper) throw new AssertionError("Wrappers should be ==.");
+
+		for (int iter = 0; iter < 2; ++iter) {
+			// First time, abort.
+			// Second time, commit.
+			try (Transaction transaction = Transaction.openOuter()) {
+				// Insert bucket from down - should fail.
+				if (downWrapper.insert(emptyBucket, 1, transaction) != 0) throw new AssertionError("Bucket should not have been inserted.");
+				// Insert bucket unsided - should go in slot 1 (isValid returns false for slot 0).
+				if (unsidedWrapper.insert(emptyBucket, 1, transaction) != 1) throw new AssertionError("Failed to insert bucket.");
+				if (!testInventory.getStack(0).isEmpty()) throw new AssertionError("Slot 0 should have been empty.");
+				if (!stackEquals(testInventory.getStack(1), Items.BUCKET, 1)) throw new AssertionError("Slot 1 should have been a bucket.");
+				// The bucket should be extractable from any side but the top.
+				if (!emptyBucket.equals(StorageUtil.findExtractableResource(unsidedWrapper, transaction))) throw new AssertionError("Bucket should be extractable from unsided wrapper.");
+				if (!emptyBucket.equals(StorageUtil.findExtractableResource(downWrapper, transaction))) throw new AssertionError("Bucket should be extractable from down wrapper.");
+				if (StorageUtil.findExtractableResource(upWrapper, transaction) != null) throw new AssertionError("Bucket should NOT be extractable from up wrapper.");
+
+				if (iter == 1) {
+					// Commit the second time only.
+					transaction.commit();
+				}
+			}
+		}
+
+		// Check commit.
+		if (!testInventory.getStack(0).isEmpty()) throw new AssertionError("Slot 0 should have been empty.");
+		if (!testInventory.getStack(1).isOf(Items.BUCKET) || testInventory.getStack(1).getCount() != 1) throw new AssertionError("Slot 1 should have been a bucket.");
+
+		checkComparatorOutput(testInventory, null);
+	}
+
+	private static boolean stackEquals(ItemStack stack, Item item, int count) {
+		return stack.getItem() == item && stack.getCount() == count;
+	}
+
+	private static class TestSidedInventory extends SimpleInventory implements SidedInventory {
+		private static final int[] SLOTS = IntStream.range(0, 3).toArray();
+
+		TestSidedInventory() {
+			super(SLOTS.length);
+		}
+
+		@Override
+		public int[] getAvailableSlots(Direction side) {
+			return SLOTS;
+		}
+
+		@Override
+		public boolean isValid(int slot, ItemStack stack) {
+			return slot != 0 || !stack.isOf(Items.BUCKET); // can't have buckets in slot 0.
+		}
+
+		@Override
+		public boolean canInsert(int slot, ItemStack stack, @Nullable Direction dir) {
+			return dir != Direction.DOWN;
+		}
+
+		@Override
+		public boolean canExtract(int slot, ItemStack stack, Direction dir) {
+			return dir != Direction.UP;
+		}
+	}
+
+	/**
+	 * Test insertion when {@link Inventory#getMaxCountPerStack()} is the bottleneck.
+	 */
+	private static void testLimitedStackCountInventory() {
+		ItemVariant diamond = ItemVariant.of(Items.DIAMOND);
+		LimitedStackCountInventory inventory = new LimitedStackCountInventory(diamond.toStack(), diamond.toStack(), diamond.toStack());
+		InventoryStorage wrapper = InventoryStorage.of(inventory, null);
+
+		// Should only be able to insert 2 diamonds per stack * 3 stacks = 6 diamonds.
+		try (Transaction transaction = Transaction.openOuter()) {
+			if (wrapper.insert(diamond, 1000, transaction) != 6) {
+				throw new AssertionError("Only 6 diamonds should have been inserted.");
+			}
+
+			checkComparatorOutput(inventory, transaction);
+		}
+	}
+
+	/**
+	 * Test insertion when {@link Item#getMaxCount()} is the bottleneck.
+	 */
+	private static void testLimitedStackCountItem() {
+		ItemVariant diamondPickaxe = ItemVariant.of(Items.DIAMOND_PICKAXE);
+		LimitedStackCountInventory inventory = new LimitedStackCountInventory(5);
+		InventoryStorage wrapper = InventoryStorage.of(inventory, null);
+
+		// Should only be able to insert 5 pickaxes, as the item limits stack counts to 1.
+		try (Transaction transaction = Transaction.openOuter()) {
+			if (wrapper.insert(diamondPickaxe, 1000, transaction) != 5) {
+				throw new AssertionError("Only 5 pickaxes should have been inserted.");
+			}
+
+			checkComparatorOutput(inventory, transaction);
+		}
+	}
+
+	private static class LimitedStackCountInventory extends SimpleInventory {
+		LimitedStackCountInventory(int size) {
+			super(size);
+		}
+
+		LimitedStackCountInventory(ItemStack... stacks) {
+			super(stacks);
+		}
+
+		@Override
+		public int getMaxCountPerStack() {
+			return 3;
+		}
+	}
+
+	private static void checkComparatorOutput(Inventory inventory, @Nullable Transaction transaction) {
+		Storage<ItemVariant> storage = InventoryStorage.of(inventory, null);
+
+		int vanillaOutput = ScreenHandler.calculateComparatorOutput(inventory);
+		int transferApiOutput = StorageUtil.calculateComparatorOutput(storage, transaction);
+
+		if (vanillaOutput != transferApiOutput) {
+			String error = String.format(
+					"Vanilla and Transfer API comparator outputs should have been identical. Vanilla: %d. Transfer API: %d.",
+					vanillaOutput,
+					transferApiOutput
+			);
+			throw new AssertionError(error);
+		}
+	}
+}